1. 长驻 Agent 为什么需要 heartbeatAgent 应用做久了会发现一个尴尬现象你写了一个能查天气、能读文件、能调接口的助手但它永远只在被提问时才动一下。用户不问它就静静躺在会话里像一台待机的老式打印机。OpenClaw 里引入的 heartbeat 心跳机制解决的正是这个被动问题——它让 Agent 在主会话中周期性运行智能体轮次主动检查有没有需要提醒你的事而不是等你开口。heartbeat 能做什么简单说它按固定间隔触发一次 Agent 轮次Agent 会读取一份叫 HEARTBEAT.md 的声明文件里面用 tasks 列出周期性任务比如每 30 分钟查一次天气、每 2 小时扫一次日程也可以写一些每次心跳都要执行的通用指令。适合谁适合做长驻型 Agent、个人助理、监控提醒类应用的开发者。底层其实就是一个定时器OpenClaw 用的是类似 setTimeout 的机制执行完才进入下一个周期默认 30 分钟。我试过把 heartbeat 接到一个本地 Agent 上第一次跑起来看到日志里每隔一分钟自动冒出一条查询广州天气那种对面是个活人的感觉确实不一样。这篇就按 OpenClaw 的 heartbeat 设计用 AgentScope 1.0.11 open-jdk-21 把配置骨架、HEARTBEAT.md 模板、定时任务触发链路和验证动作完整走一遍。代码参考 claw-step6-heartbeat是在 claw-step5-skill 基础上改的所以下面只展开新增和修改部分。2. TaoToken 前置给 heartbeat 准备模型通道heartbeat 每次触发都要调用一次大模型如果模型通道不稳定心跳就会变成心跳骤停。所以先把模型接入这块理顺。TaoToken 提供统一的模型调用入口适合放在 heartbeat 这种高频、自动化的场景里避免每次手动换 key。你需要先拿到 API Key然后确认接入地址。整个流程分两步注册后在控制台创建 key再按文档把 base_url 和 key 填进项目配置。heartbeat 任务本身不关心你用哪个模型它只负责把拼好的 prompt 交给 AgentAgent 再走模型通道。所以模型通道配好一次后面所有心跳都复用。具体入口我列一下按需点注册与登录https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content创建 API Keyhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content模型对话体验https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content注意API 基础地址是 https://taotoken.net/api不要带 UTM 参数配置里填这个就行。如果你只是先验证 heartbeat 能不能触发可以先用模型对话页面确认 key 可用再回到项目里配。长期跑编码类或 Agent 类任务可以考虑 Coding Plan心跳频率高的时候额度更稳。这一步不展开注册教程重点是把 key 和 base_url 拿到手下一节直接进配置。3. 可复制配置config.toml 心跳段与 HEARTBEAT.md 模板OpenClaw 原版的 heartbeat 配置是 JSON 风格字段比较全包括 every、target、directPolicy、lightContext、isolatedSession、skipWhenBusy、activeHours 等。我们这次在 AgentScope 里只实现间隔时间其它先不实现但配置骨架保留方便以后扩展。下面给出可复制的 config.toml 心跳段对应项目里的 claw.json这里用 TOML 形式表达更清晰。# config.toml —— heartbeat 心跳段 [heartbeat] every 30 # 心跳间隔单位分钟默认 30 target last # 明确交付至最后联系人默认 none direct_policy allow # 允许直接/私信目标block 则禁止 light_context true # 仅从引导文件中注入 HEARTBEAT.md isolated_session true # 每次运行开启全新会话无对话历史 skip_when_busy true # 下层代理或嵌套通道忙时延迟处理 # active_hours { start 08:00, end 24:00 } # 活动时间窗 # include_reasoning true # 可选额外发送 Reasoning 消息对应到项目里的 claw.json实际写法是这样{ memory: { vector: 3, bm25: 1 }, tool: [ { code: fileSystem, name: 系统类工具, enable: false, tools: [ReadFileTool, WriteFileTool] }, { code: runtime, name: 运行时类工具, enable: true, tools: [ShellCommandTool] }, { code: web, name: 网络类工具, enable: true, tools: [] }, { code: memory, name: 记忆类工具, enable: true, tools: [] }, { code: UI, name: UI类工具, enable: true, tools: [] } ], skill: { disable: [weather] }, heartbeat: { every: 30 } }然后是 HEARTBEAT.md 模板。这个文件放在 resources/workspace 下格式分两块tasks 是结构化任务每条有 name、interval、prompttasks 之外的文本属于通用指令每次心跳都会执行。定期检查以下项目 - 检查磁盘空间 tasks: - name: 天气查询 interval: 30m prompt: 定期查询广州天气然后发送给我 # Additional instructions - Keep alerts short. - If nothing needs attention after all due tasks, reply HEARTBEAT_OK.interval 的语义要特别注意它不是每 30 分钟必执行一次而是距离上次执行超过 30 分钟才执行。比如任务设 40 分钟间隔而心跳每 30 分钟跑一次那么第 0 分钟执行一次第 30 分钟因为没到 40 分钟不执行要等到第 60 分钟那次心跳才执行。这个细节在验证阶段很容易被误判成心跳没触发后面排障会专门讲。4. 定时任务触发链路从声明到执行这一节把链路拆开HEARTBEAT.md 解析 → 任务到期判断 → 拼 prompt → 调模型 → 结果推回客户端。核心类有四个Task、TaskState、TimeParser、HeartBeatTask再加一个 HeartBeatScheduler 做定时器。先看 Task 和 TaskState一个存任务定义一个存上次执行时间package com.lin.claw.heartbeat; public class Task { String name; String interval; String prompt; Long lastExec; // getter / setter 省略 }package com.lin.claw.heartbeat; import java.util.HashMap; import java.util.Map; public class TaskState { MapString, Long taskStateMap; public MapString, Long getTaskStateMap() { if (taskStateMap null) { taskStateMap new HashMap(); } return taskStateMap; } // setter 省略 }TimeParser 负责把 30m、1h30m、1d2h 这类字符串转成毫秒。正则(\d)([a-zA-Z])抓数字和单位单位映射到 TimeUnitpackage com.lin.claw.heartbeat; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; public class TimeParser { private final static MapString, TimeUnit DURATION_MULTIPLIERS Map.of( ms, TimeUnit.MILLISECONDS, s, TimeUnit.SECONDS, m, TimeUnit.MINUTES, h, TimeUnit.HOURS, d, TimeUnit.DAYS ); public static long parseToMillis(String timeString) { if (timeString null || timeString.isEmpty()) { return 0; } Pattern pattern Pattern.compile((\\d)([a-zA-Z])); Matcher matcher pattern.matcher(timeString.toLowerCase()); long totalMillis 0; while (matcher.find()) { long value Long.parseLong(matcher.group(1)); String unit matcher.group(2); TimeUnit timeUnit DURATION_MULTIPLIERS.get(unit.toLowerCase()); if (timeUnit ! null) { totalMillis timeUnit.toMillis(value); } } return totalMillis; } }HeartBeatTask 是真正干活的地方实现 Runnable。它做五件事解析 HEARTBEAT.md、算出本次要执行的任务、拼 prompt、调 Agent、把结果通过 SSE 推给客户端。解析部分用正则逐行扫遇到tasks:进入任务区遇到- name:开新任务遇到interval:和prompt:填字段。任务到期判断逻辑是如果 taskState 里没有记录说明从没执行过本次执行如果有记录比较currentTime period lastExec。package com.lin.claw.heartbeat; import com.lin.claw.constants.Constant; import com.lin.claw.constants.PromptConstant; import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.agui.encoder.AguiEventEncoder; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.registry.AguiAgentRegistry; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.spring.boot.agui.mvc.AguiMvcController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.sql.Timestamp; import java.text.MessageFormat; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; public class HeartBeatTask implements Runnable { private static final Logger log LoggerFactory.getLogger(HeartBeatTask.class); private final static String HEART_MD HEARTBEAT.md; private final static TaskState taskState new TaskState(); private AguiAgentRegistry aguiAgentRegistry; public HeartBeatTask(AguiAgentRegistry aguiAgentRegistry) { this.aguiAgentRegistry aguiAgentRegistry; } Override public void run() { System.out.println(任务开始执行: new Timestamp(System.currentTimeMillis())); try { executeTasks(); } catch (IOException e) { log.error(heartbeat execute error:, e); Thread.currentThread().interrupt(); } System.out.println(任务结束执行: new Timestamp(System.currentTimeMillis())); } private void executeTasks() throws IOException { String runId run-default; if (AguiMvcController.sseEmitterMap.get(runId) null) { log.warn(sseEmitter is null); return; } URL workspaceURL ClassLoader.getSystemResource(Constant.WORKSPACE_DIR); File file new File(workspaceURL.getPath() File.separator HEART_MD); ListTask tasks new ArrayList(); Pattern keyValPattern Pattern.compile(^\\s*-?\\s*(\\w):\\s*(.)$); boolean inTasksSection false; Task currentTask null; ListString lines Files.readAllLines(file.toPath()); for (String line : lines) { if (line.trim().equals(tasks:)) { inTasksSection true; continue; } if (!inTasksSection) continue; if (!line.isEmpty() !line.startsWith( ) !line.startsWith(\t)) { inTasksSection false; if (currentTask ! null) { tasks.add(currentTask); currentTask null; } continue; } if (line.trim().startsWith(- name:)) { if (currentTask ! null) tasks.add(currentTask); currentTask new Task(); Matcher matcher keyValPattern.matcher(line); if (matcher.find()) currentTask.name matcher.group(2).trim(); } else if (currentTask ! null) { Matcher matcher keyValPattern.matcher(line); if (matcher.find()) { String key matcher.group(1); String value matcher.group(2).trim(); if (value.startsWith(\) value.endsWith(\)) { value value.substring(1, value.length() - 1); } if (key.equals(interval)) currentTask.interval value; else if (key.equals(prompt)) currentTask.prompt value; } } } if (currentTask ! null) tasks.add(currentTask); byte[] bytes Files.readAllBytes(file.toPath()); String heartbeatFileContent new String(bytes, StandardCharsets.UTF_8); String regex (?m)(^tasks:\\s*\\n(?:(?: {2}.*\\n)|\\n(? {2}))*); String directives heartbeatFileContent.replaceAll(regex, ); log.info(directives); ListTask execTask new ArrayList(); long currentTime System.currentTimeMillis(); tasks.forEach(task - { if (taskState.getTaskStateMap().get(task.getName()) null) { execTask.add(task); } else { Long lastExec taskState.getTaskStateMap().get(task.getName()); long period TimeParser.parseToMillis(task.getInterval()); if (period 0 currentTime (period lastExec)) { execTask.add(task); } } }); String result ; if (!execTask.isEmpty() || !directives.isEmpty()) { if (!execTask.isEmpty()) { StringBuilder sb new StringBuilder(); execTask.forEach(task - { sb.append( - ).append(task.getName()); if (task.getPrompt() ! null !task.getPrompt().isEmpty()) { sb.append(: ).append(task.getPrompt()); } sb.append(\n); }); result MessageFormat.format(PromptConstant.HEARTBEAT_TASK_PROMPT, sb.toString()); } if (!directives.isEmpty()) { result result PromptConstant.HEARTBEAT_CONTEXT_PROMPT directives \n; } result result 当前时间 new Date(); } log.info(heartbeat prompt: {}, result); if (!result.isEmpty()) { Agent agent aguiAgentRegistry.getAgent(default).get(); if (agent instanceof ReActAgent reActAgent) { Msg msg Msg.builder().role(MsgRole.USER).textContent(result).build(); Msg response reActAgent.call(msg).block(); String responseText response.getTextContent().replace(HEARTBEAT_OK, ); log.info(responseText); if (!responseText.isEmpty()) { AguiEvent aguiEvent new AguiEvent.TextMessageContent( Constant.SESSION_KEY, runId, UUID.randomUUID().toString(), responseText); AguiEventEncoder encoder new AguiEventEncoder(); String jsonData encoder.encodeToJson(aguiEvent); AguiMvcController.sseEmitterMap.get(runId) .send(SseEmitter.event().data(jsonData, MediaType.APPLICATION_JSON)); } } } execTask.forEach(task - taskState.getTaskStateMap().put(task.getName(), currentTime)); } }定时器用 Java 自带的 ScheduledExecutorService 就够单线程调度首次立即执行之后按 claw.json 里的 every 分钟延迟。注意用的是 scheduleWithFixedDelay任务结束后才开始计时避免任务执行时间超过间隔时堆叠。package com.lin.claw.heartbeat; import com.lin.claw.config.ClawConfig; import io.agentscope.core.agui.registry.AguiAgentRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; Component public class HeartBeatScheduler implements CommandLineRunner { Autowired private ClawConfig clawConfig; Autowired private AguiAgentRegistry aguiAgentRegistry; Override public void run(String... args) { ScheduledExecutorService executor Executors.newSingleThreadScheduledExecutor(); int delay 30 * 60 * 1000; if (clawConfig.getHeartbeat() ! null clawConfig.getHeartbeat().getEvery() ! null) { delay clawConfig.getHeartbeat().getEvery() * 60 * 1000; } executor.scheduleWithFixedDelay(new HeartBeatTask(aguiAgentRegistry), 0, delay, TimeUnit.MILLISECONDS); } }还有一处关键改动原 AG-UI 每次请求都新建 SSE心跳结果没法推回已有连接。所以把 AguiMvcController 里的 sseEmitterMap 改成按 runId 复用前端固定 runId 为 run-default心跳就能往这个通道里发消息。前端 index.html 里把 runId 写死并在 Agent 菜单加一个 heartbeat.md 的编辑 tab方便直接改声明文件。5. 验证请求与成功结果配置齐了怎么确认心跳真的按预期触发分三步。第一步启动项目访问 http://localhost:8080/index.html进 Agent 菜单切到 heartbeat.md tab确认能加载并编辑 HEARTBEAT.md。为了演示方便把 claw.json 里的 every 改成 1也就是 1 分钟一次心跳。第二步在聊天界面输入你好。这一步不是为了聊天而是为了建立 SSE 连接——代码里判断sseEmitterMap.get(runId) null就直接 return没有通道心跳不会执行。发完消息后控制台会打印任务开始执行和任务结束执行的时间戳。第三步观察日志和界面。等 2 分钟日志里应该出现两次心跳。第一次执行了 2 个任务通用指令 天气查询第二次只执行 1 个任务因为天气查询设的是 30 分钟间隔第二次还没到期。界面上会看到 Agent 主动推来的天气消息不需要你再提问。任务开始执行: 2026-01-15 10:00:00.123 heartbeat prompt: 定期检查以下项目... 任务结束执行: 2026-01-15 10:00:03.456 任务开始执行: 2026-01-15 10:01:00.789 heartbeat prompt: 定期检查以下项目... 任务结束执行: 2026-01-15 10:01:02.012看到这个节奏说明心跳链路通了定时器触发 → 解析 HEARTBEAT.md → 到期判断 → 调模型 → SSE 推送。如果只想验证模型通道可以先去模型对话页面发一条确认 key 和 base_url 没问题再回来看心跳日志。6. 本篇常见错排查心跳不触发先查三处。第一SSE 通道有没有建立。日志里如果反复出现sseEmitter is null说明你没在聊天界面发过消息或者前端 runId 和代码里的 run-default 不一致。第二HEARTBEAT.md 路径对不对。代码用ClassLoader.getSystemResource(Constant.WORKSPACE_DIR)找文件如果 workspace 目录没打进 resources会直接抛 IOException。第三interval 语义误解。任务设 30m、心跳设 1m第一次执行后要等 30 分钟才会再执行中间 29 次心跳都会跳过它这是正常的不是 bug。另一个高频坑是 interval 写成30不带单位。TimeParser 只认30m、1h这种带单位的格式纯数字会解析成 0代码里period 0判断为 false任务永远不执行日志会打task interval error。还有 prompt 里的引号如果写成prompt: 查询天气解析时会自动去掉首尾引号但如果引号不配对value 会带着半个引号进 prompt模型输出会怪怪的。最后是任务状态丢失。TaskState 是内存里的 Map重启项目后所有 lastExec 清空第一次心跳会把所有任务都执行一遍。如果你不希望重启后立刻全量触发可以把 TaskState 持久化到文件或数据库这个留作扩展。接入和排障相关的入口放这里按需取API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content心跳跑通之后建议先把 every 调回 30再把 HEARTBEAT.md 里的任务按真实需求写。别一上来就塞十几个任务模型每轮都要处理间隔太密既费额度也容易刷屏。
企业数字化 ERP 产品动态
相关推荐
AI短剧工业化流水线:6步可落地的全流程生产方法论 1. 这不是“AI视频课”,而是一套可落地的短剧工业化流水线最近在B站刷到一个标题特别扎眼的教程:“【LibTV教程】目前B站最详细的一站式制作教程!从剧本、分镜、人物生成、视频、配音到剪辑完整演示,零基础手把手操作,… · 2026/9/26 3:18:03
Python电商用户行为分析系统:从数据清洗到可视化看板的完整实现 又到一年毕设季,后台天天有人问同一个问题:有没有既好做、又有亮点、还能顺利过答辩的题目?今天聊的这套基于Python的电商用户行为分析系统,是我这几年看到性价比最高的大数据毕设选题之一。它不卷算法、不拼GPU,用最务… · 2026/9/26 3:17:57
智慧校园供应商资质预审:黄金法则与避坑实录 写智慧校园供应商选择这个话题,我是有点底气的。这十来年我经手过从区县职校到重点高校的十几个智慧校园项目,前期供应商考察、资质预审这个环节,我至少跟上百家厂商打过交道。说句实话,智慧校园建设的成败,很多时候在… · 2026/9/26 3:17:57
【频道】防入侵!OpenClaw 本地部署对接 QQ:从部署到安全权限锁死全流程 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:59:22
VScode 前端开发配置 TaoToken:settings.json 骨架与验证动作 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:59:22
OpenClaw 插件系统实战:用 Manifest 扩展你的 AI Agent 边界 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:59:22
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46