资讯动态

自己动手写Agent Harness【agent memory】:让它有记忆(会话日志、分叉与恢复)

发布时间:2026/8/24 14:02:11 来源:尧图企业网站定制
写在前面系列是为了帮助大家更好的去理解Agent Harness基础设施并不是想重复造轮子真实开发建议选择一个成熟的SDK或Harness框架才是最合适的选择~1. 模型上次请求见过什么你的 agent 跑错了现在只有一条路重启。跑崩了重启跑岔了也重启回不到「刚才那个状态」。dsh 能分叉、能回放、能恢复。差在哪不在模型在会话怎么存。这是 harness 的分水岭。前三篇把 loop、工具、安全都装上了循环会转了有了手和眼睛划了安全边界。可它们有个共同的短板——会话只是内存里一个数组进程一死就清零。这一篇给它记忆。模型上次请求见过什么这个问题你答得出debug 才有入口答不出agent 跑错只能靠猜。会话怎么存决定你能不能答出这个问题。这篇写四件事append-only 事件日志、从日志派生消息、分叉平行会话、崩溃后恢复。2. 先看内存版会话的局限step1 的 loop 里会话是这么个东西this.history[]// 当前上下文含 system模型每次请求把 system 拼上这个数组发过去模型回一句push 进去。这个模型我们用了三篇。它的问题不是「简单」是会话活在了运行时内存里。崩溃就丢。进程一死数组没了上下文清零模型失忆。你想继续上次没跑完的活只能重说一遍。无法回放。没有痕迹跑错只能盯屏幕复盘跑完三十分钟你都不知道它中间干了什么。无法分叉。想从「刚才那个状态」开一条平行路径数组没有边界概念slice 出来也是一锅粥。这三个缺陷不是补丁能救的它们同根同源会话 运行时内存这个模型本身就错了。内存易失、可变、没有边界你把「记忆」放在这样一个载体上分叉、回放、恢复这些功能就永远长不出来。先把两个概念分清会话这段对话的全部事实和模型历史每次请求喂给模型的那些消息。内存版把这两个当成同一个东西——一个数组。dsh 的做法是把它们拆开会话是一条事件流模型历史只是它的一次投影。3. 换成 append-only 事件日志不存「消息数组」存「事件流」。每条事件带一个seqseq就是log.length写进去之后立刻deepFreeze从此不可变。classSessionLog{constructor(){this.events[]// append-only只能 push不能改this.boundary{}// 记录每个 boundary 事件的 seqfork 时用}// append唯一的写入口。seq 就是 log.length对照 dsh index.ts:604// 每个事件写完后 deepFreeze从此不可变。append(event){event{...event,seq:this.events.length}deepFreeze(event)this.events.push(event)returnevent}addBoundary(){constevthis.append({type:boundary,marker:turn/start})this.boundary[turn/start]ev.seqreturnev}}seq为什么取log.length因为这是最省事的单调序列天然递增、天然连续、天然可寻址——第 N 条事件就是events[N]。不需要单独的计数器append 的时候数一下日志有几条就是它的编号。事件按角色分三类我叫它三域boundary边界turn/start这类标记不产生模型可见消息只标记位置——分叉时切在哪就认它surface表面user、assistant、toolResult真正进模型历史的东西log-only仅日志chunk、usage 这类为保真和回放存在但不进派生历史。functiondeepFreeze(obj){if(objtypeofobjobject!Object.isFrozen(obj)){Object.freeze(obj)for(constkofObject.keys(obj))deepFreeze(obj[k])}returnobj}deepFreeze 递归冻结保证写进去的事件连嵌套字段都改不动。日志是不可变的事实谁也别想改——这听起来像洁癖其实是分叉和回放的地基如果历史随时能被改那「从某个点切出去」就失去意义了。这一节做完你已经跨过了那个分水岭的一半会话从「运行时内存」搬到了「append-only 日志」——当数据库管不是当内存管。为什么这是分水岭下一节讲完三个函数你就看全了。4. 从日志派生一切事件流有了怎么变回模型能看的历史先看全貌再逐项拆。三类事件都 append 进同一条不可变日志模型看到的永远是 deriveMessages 的投影渲染错误:Mermaid 渲染失败: Parse error on line 8: ...nd B -- E[(events[]seq log.len ----------------------^ Expecting SQE, DOUBLECIRCLEEND, PE, -), STADIUMEND, SUBROUTINEEND, PIPE, CYLINDEREND, DIAMOND_STOP, TAGEND, TRAPEND, INVTRAPEND, UNICODE_TEXT, TEXT, TAGSTART, got SQS三个函数逐个拆。第一个deriveMessages——投影。过滤掉 boundary 和 log-only只留 surface映射成模型历史。模型看到的从来不是日志本身是每次从日志现算的投影。// deriveMessages事件流 → 模型历史。// 过滤掉 boundary纯标记和 log-only只留 surface真正进模型的。// 这就是「日志是唯一真相其它全是投影」——模型看到的从来不是数组// 是每次从日志现算的投影。deriveMessages(){returnthis.events.filter((e)e.typesurface).map((e){if(e.roleassistante.toolCall)return{role:assistant,toolCall:e.toolCall}if(e.roletoolResult)return{role:toolResult,toolName:e.toolName,content:e.content}return{role:e.role,content:e.content}})}第二个resume——崩溃后重放。把磁盘上的日志读回来逐条塞回内存会话状态就重建了。因为每条事件带原始seq重放时不重新分配保持原编号——这样恢复出来的会话和崩溃前一模一样一个字节都不差。// 静态恢复从磁盘重放日志重建会话状态resumestaticasyncresume(pathLOG_FILE){constlognewSessionLog()try{constrawawaitreadFile(path,utf8)for(constlineofraw.trim().split(\n)){if(!line)continueconstevJSON.parse(line)log.events.push(Object.freeze(ev))// 重放不重新分配 seq保持原 seq}}catch{// 文件不存在 全新会话}returnlog}第三个fork——按 boundary 切子会话。从最后一次turn/start的 seq 处切开前半段深拷贝进子会话。父会话继续走自己的路子会话从同一历史出发各跑各的。// fork按 boundary seq 切子会话 —— 从最后一次 turn/start 分叉fork(){constcutthis.boundary[turn/start]??0constchildnewSessionLog()child.eventsthis.events.slice(0,cut1).map((e)Object.freeze({...e}))child.boundary{...this.boundary}returnchild}三个函数加起来我压成一句日志是唯一真相其它全是投影。模型历史是投影UI 渲染是投影回放是投影分叉是投影恢复也是投影——都是对同一条不可变事件流的不同读法。内存版做不到这些不是写代码的人不行是「会话 数组」这个模型根本没给这些功能留位置。到这里我要立一个判断它可能有人不同意会话当数据库管还是当运行时内存管是自建 harness 的分水岭——先想清楚再写代码。内存版一天能写出来但分叉、回放、恢复每一样都是打补丁数据库版一天半写出来这三样是白送的。选型差半天能力的差距是数量级的。我还有个关子先卖你要给它焊一条**「模型可见即已记录」**的不变量——dsh 把它写成断言不是约定为什么必须是断言后面揭晓。先看代码跑起来。5. 跑通它崩溃后恢复代码在examples/first-agent/step4-session/index.js克隆即跑gitclone本仓库地址cdexamples/first-agentnodestep4-session/index.js不需要npm install不需要 API key默认走llm/mock.js。想换真实模型设三个环境变量再跑同一条命令OPENAI_BASE_URLhttps://api.deepseek.com\OPENAI_API_KEYsk-xxx\OPENAI_MODELdeepseek-chat\nodestep4-session/index.jscomplete(messages, tools)这个签名在 mock 和 real 之间完全一致换 provider 一行改——这是 之前系列讲过的「模型适配层越薄越稳」。我本机跑了一遍Windows 11 / Git Bash / Node v22.12.0输出逐字如下$ node step4-session/index.js [user] 读文件 package.json [assistant] 工具 read_file 返回了{ name: first-agent, version: 0.1.0, private… [user] 你好记住我是在搭 agent [assistant] mock收到你好记住我是在搭 agent —— 日志已落盘共 8 条事件 —— —— 模拟崩溃丢弃内存从磁盘恢复 —— 恢复后事件数 8 应等于落盘数 [user] 继续我刚才说了什么 [assistant] mock收到继续我刚才说了什么 —— 分叉从最后一次 turn/start 切子会话 —— 子会话事件数 9 父 11 子会话尾部 {type:boundary,marker:turn/start,seq:8}三段演示三个真实证据逐个拆。证据一落盘 8 条崩溃恢复后仍 8 条。前两轮对话写成了 8 条事件。拆开看就是前文那三类seqtyperole内容0boundary-turn/start第 1 轮1surfaceuser读文件 package.json2surfaceassistanttoolCall: read_file3surfacetoolResult文件内容4surfaceassistant工具 read_file 返回了…5boundary-turn/start第 2 轮6surfaceuser你好记住我是在搭 agent7surfaceassistantmock收到…seq从 0 到 7正好 8 条。然后模拟崩溃——把内存里的log整个丢弃SessionLog.resume()从磁盘重放。恢复后事件数仍是 8resume 是完整的没有丢半条。证据二恢复后新 turn 正常跑。恢复出来的会话接着问「继续我刚才说了什么」——mock 模型这次不靠猜它看到了完整历史最后一条 user 是「继续」前面有整个前两轮的 surface 事件正常回了话。这行输出能出现说明 loop 里的不变量断言通过了——否则deepStrictEqual直接抛错程序当场崩。这就是「模型可见即已记录」在起作用恢复后的请求历史 日志派生的结果不变量成立运行继续。这个断言长在step()里每次请求前先验一遍asyncstep(){constmessages[systemMessage(SYSTEM_PROMPT),...this.log.deriveMessages()]// 不变量检查请求前确认历史与日志一致对照 dsh invariant.ts:21-54// 这不是约定是断言——不一致直接 fail而不是悄悄继续。constderivedthis.log.deriveMessages()constexpectedthis.log.events.filter((e)e.typesurface).map((e){if(e.roleassistante.toolCall)return{role:assistant,toolCall:e.toolCall}if(e.roletoolResult)return{role:toolResult,toolName:e.toolName,content:e.content}return{role:e.role,content:e.content}})deepStrictEqual(derived,expected,invariant violated: derived messages ! log)constoutawaitthis.llm.complete(messages,this.registry.schemas())if(out.toolCall)this.log.addToolCall(out.toolCall)elsethis.log.addAssistant(out.text)returnout}诚实说明step4 这版断言比对的是「deriveMessages()的结果」和「手写规范投影」验的是派生逻辑有没有漂移比 dsh 弱一档。dsh 在同一个位置比对的是真正发给 llm 的消息数组和session.deriveMessages()下面第 6 节细讲。位置和意图一样力度我留给你加。证据三分叉从最后一次 boundaryseq 8切出子会话。恢复后那个「继续」的 turn 又写了 3 条boundary seq 8、user seq 9、assistant seq 10父会话到这儿是 11 条。fork()认最后一次turn/start——seq 8slice(0, 9)切出前 9 条0 到 8。所以子 9、父 11子会话尾部就是{type:boundary,marker:turn/start,seq:8}——它从第 3 轮开头分出去第 3 轮还没发生。父继续第 3 轮子走自己的第 3 轮两个平行世界共享前两轮记忆。真实踩坑Windows 专属必踩写盘路径我最初用的是new URL(./session.jsonl, import.meta.url).pathname。Windows 上报错ENOENT: D:\D:\...\session.jsonlpathname在 Windows 上返回的是带盘符前缀的/D:/...Node 的 fs 拿到它前面那个/被吞掉拼出D:\D:\...——盘符重复。修复是把.pathname换成fileURLToPathconstLOG_FILEfileURLToPath(newURL(./session.jsonl,import.meta.url))这个坑和之前遇到的 dsh 的file:///D:/前缀坑同源ESM 的import.meta.url在 Windows 上是个文件 URL不是干净路径。.pathname给你半截fileURLToPath才给你能用的盘符路径。写 Node ESM 落盘认准这一个函数。跑完程序会清掉演示用的session.jsonlrm(LOG_FILE, { force: true })不污染仓库。6. 对照 dsh「模型可见即已记录」是运行时不变式不是约定step4 的每个设计决策dsh 里都有对应的源码。逐个对照。事件模型——packages/core/session/src/types.ts:404定义了SessionEvent{ type, seq, time, data }seq单调递增。append——index.ts:604的append()seq: this.log.length和 step4 一模一样写死。事件必须是无损 JSONBigInt、Date、函数直接拒收写进去前先deepFreeze。派生——index.ts:726的deriveMessages()把事件流投影成模型历史。dsh 比 step4 多一层 surface 管理user/message、assistant/message、tool/result五个产消息类型压缩可以 shadow 旧节点但核心就是一句话模型历史永远从日志现算不是存着的一份数组。分叉——index.ts:1081的fork(source, boundary?)按 boundary seq 从源会话切一个子会话子会话元数据带parentSession和seedLength。它比 step4 严一条要求切的前缀不能落在未结束的 turn 里否则拒绝而不是静默截断。我抄的时候简化了这一步 dsh 是对的——分叉点必须是稳定位置你才敢从那里开平行世界。不变量——packages/core/agent-loop/src/invariant.ts:21-54agent-loop 用 llm 请求前的 hook 焊死这条规则。它检查的东西比 step4 多得多请求必须 frozen、必须带 sessionId、日志里必须有step/start和request/header、options.messages必须和session.deriveMessages()逐字节相等、连 model/system/temperature/maxTokens/stop/tools 都得和 header 对得上。核心是第 40 行那句if(JSON.stringify(options.messages)!JSON.stringify(expected)){fail(llm request ... diverges from the dispatch-time durable derivation (log-reconstruction desync))}现在兑现第 5 节那个关子为什么必须是断言不是约定因为约定靠自觉断言不靠。你写进 README 一句「大家记得历史从日志派生」第一次重构就有人忘你把检查焊在请求前忘了就直接崩——崩是好事崩在发出请求之前而不是让模型带着一份漂移的历史跑完一轮再说。约定会被违反断言不会。dsh 连注释都写明了Prepend prevents a short-circuiting replay listener from silencing the check——它怕监听器短路绕过所以抢占最前面。这种防御性不是洁癖是「模型可见即已记录」必须焊死松一处回放和分叉的地基就漏风。那 Claude Code 和 Codex 呢写作日我 WebSearch 核了官方文档。Claude Code 把会话做成产品功能checkpoint 在每个用户提示前自动存快照/rewind回退到任意检查点claude --resume//resume恢复会话会话按项目目录存在~/.claude/projects/.../*.jsonl。Codex 也是产品功能会话自动存成~/.codex/sessions/下的 JSONL rolloutcodex resume/codex continue接着跑/fork克隆当前会话。区别不在功能多寡在架构地位dshClaude CodeCodex会话形态append-only SessionEvent 事件流会话 JSONL checkpoint 快照~/.codex/sessions/ JSONL rollout当什么管数据库唯一真相其它全投影产品功能产品功能分叉fork 按 boundary seq架构原语/branch、–continue --fork-session/fork恢复resume 重放日志纯函数claude --resume / /resumecodex resume / continue不变式断言模型可见即已记录无架构断言无架构断言CC 和 Codex 的恢复对用户够用但它们没有一条断言保证「模型见到的必须能从历史重建」——会话状态是产品封装不是架构约束。dsh 的持久化在写作日又往前走了一步官方仓库把会话持久化做成抽象服务接缝ctx.sessionPersistence同一份接口两个后端——JSONL.jsonl.zstd校验和帧或裸.jsonl和 SQLitenode:sqlite WAL持久化的单元就是SessionEvent本身不再另造一套「持久化消息」。崩溃的 turn 用风险分级的合成错误结果「关闭」而不是截断恢复时只修最后一个不完整记录、从不改写已提交的事件。这些细节我写作日 WebSearch 核了官方仓库HEAD 47f9438MIT方向和我抄的 step4 完全一致日志是唯一真相别的全是投影。session 子系统在持续演进但「事件即真相」这条主线没变过。对要搭 harness 的人这是我最想让你抄的一条dsh 的「模型可见即已记录」不用抄成完整断言但要抄成习惯——先回答一个问题模型上次请求见过什么答不出debug 无从谈起。7. 结论先当数据库管你的 harness 才有资格 debug一句话把会话从「运行时内存」搬进「append-only 日志」分叉、回放、恢复就从补丁变成白送。内存版一删日志版一加你的 harness 第一次有资格回答「模型上次请求见过什么」。崩溃能恢复跑错能回放想试一条新路能从任意一个 turn 分叉——这三样不是功能列表是「会话当数据库管」这个模型自动长出来的。我踩过的那个 Windows 盘符坑提醒你落盘路径用fileURLToPath别用.pathname。模型适配的complete()签名保证换 provider 一行改session 层完全不用动。行动钩给你给你的 harness 加一条 append-only 日志试试崩溃后恢复。想深入了解哪一块也评论区告诉我。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价