Agent Harness 的多轮进化:从单次 Run 内的工具循环,到跨 Run 的长期记忆

📅 2026/7/2 3:39:52
Agent Harness 的多轮进化:从单次 Run 内的工具循环,到跨 Run 的长期记忆
Agent Harness 的多轮进化从单次 Run 内的工具循环到跨 Run 的长期记忆系列博客第四篇 · 2026-07-01今天的主角是“多轮”——两种不同层次的多轮一、前情回顾三天时间我的 Mini Agent harness 走过了这样一条路Day 1最小闭环跑通用户输入 → 模型 → 工具 → 输出。Day 2Context Builder 统一上下文Trace Report 让 JSON 变可读。Day 3Trace 事件细化context_built/engine_started新增search_web/run_shell工具以及自动错误恢复Error Recovery。今天Day 4的核心主题是“多轮”。这里的“多轮”其实包含两个完全不同的层次单次 Run 内的多轮Intra-run turns一次npm run dev执行过程中模型和工具可能来回交互很多次最终才给出答案。跨 Run 的多轮Session你退出程序、下次再启动Agent 还能记得上次聊过什么。这两个层次我都在今天实现了而且都踩了不小的坑。这篇文章会重点讲这两个部分的设计思路和踩坑过程附带简单提一下今天新增的第三个工具edit_file。二、单次 Run 内的多轮Turn Tracking2.1 之前的问题一次 Run 只有一轮之前的架构是这样的用户输入 → 模型决定调用工具 → 工具执行 → 模型生成最终答案 → 结束。看起来没问题对吧但实际情况中模型经常需要多次调用工具才能完成一个任务。举个例子用户说“列出根目录文件然后读取 README.md 前 20 行。”模型可能需要先调用list_files获取根目录文件列表。再调用read_file读取 README.md 的内容。最后根据两次工具的结果整理成表格输出。但之前我的系统在第一次工具调用后就默认进入“结束”流程根本没给模型第二次调用工具的机会。这是一个严重的逻辑缺陷。2.2 解决方案把 Run 拆成多个 Turn我重新设计了执行循环Run 开始 - Turn 1 开始 - 模型调用工具 A - 工具 A 执行完成 - 模型根据结果决定继续调工具 B还是直接回答 - Turn 1 结束 - Turn 2 开始如果模型决定继续 - ... - Turn N 结束 - Run 结束为了实现这个逻辑我在src/runtime/events.ts里新增了四个 Turn 相关事件事件含义turn_started第 N 轮开始记录当前 messages 数量turn_model_response模型返回调了几个工具、assistant 预览turn_context_snapshot本轮结束后 messages 快照用于复盘turn_done本轮结束附带结束原因final_answer直接回答/tool_observations继续下一轮/max_turns达到上限2.3 实例演示一次完整的 3-turn 执行为了让你直观感受 turn 机制的效果我实际跑了一个任务npm run dev--列出根目录文件然后读取 README.md 前 20 行系统自动完成了 3 个 TurnTurn 1模型决定先调用list_files观察环境。turn_started (turn1, messages2) - turn_model_response (tools1) - tool_call list_files - tool_result ok (75 files, 13ms) - turn_context_snapshot (messages4) - turn_done (reason: tool_observations)Turn 2模型拿到文件列表后决定读取README.md。turn_started (turn2, messages4) - turn_model_response (tools1) - tool_call read_file - tool_result ok (8180 bytes, 1ms) - turn_context_snapshot (messages6) - turn_done (reason: tool_observations)Turn 3模型拿到 README 内容后认为信息足够了直接生成最终回答。turn_started (turn3, messages6) - turn_model_response (tools0, assistantPreview 开始输出) - turn_done (reason: final_answer, finishedtrue) - text_delta (输出答案) - done最终输出模型整理了一份清晰的 Markdown 表格包含根目录关键文件列表和 README 前 20 行内容。整个过程用时约11 秒其中工具执行只花了14mslist_files13ms read_file1ms其余时间都在等模型推理。这说明多轮对话的瓶颈在模型推理速度而不在工具执行。对应的 Trace Report 自动生成了「多轮执行Turn」章节- Turn 1tools1 (list_files) | finishedfalse | reasontool_observations - Turn 2tools1 (read_file) | finishedfalse | reasontool_observations - Turn 3tools0 | finishedtrue | reasonfinal_answer一眼就能看出这次 run 是怎么一步步完成的。2.4 踩坑实录 1模型“撒谎”说它完成了坑点模型有时候会在turn_model_response里说“我已经完成了任务”但实际上它根本没有调用任何工具或者工具还没执行完。原因DeepSeek 的finish_reason有时候是stop但模型只是暂停思考并没有真正给出最终答案。它可能在想“我还需要再调用一个工具但我先暂停思考”。解法我在turn_done的判断逻辑里增加了一条规则如果本轮模型没有调用任何工具但finish_reason是stop不要立刻标记为final_answer而是把模型输出作为text_delta流式返回然后让用户决定是否继续。但仔细一想这会把交互逻辑推给用户违背了“自动化”的初衷。最终我的做法是如果模型没有调用工具且finish_reason是stop就当作final_answer处理但在 trace 里标记为early_stop方便后续分析模型行为。这个坑让我意识到模型的行为并不总是符合直觉的你需要在实际运行中不断调整对“完成”的判断标准。2.5 踩坑实录 2Turn 的边界在哪里坑点turn_started和turn_done之间的边界到底怎么定义是“模型每一次 API 调用算一轮”还是“模型每一次调用工具算一轮”选择我最终以“模型是否调用了工具”作为 Turn 的切换依据。如果模型调用了工具那工具执行完后模型会进入下一轮重新调用模型 API。如果模型没有调用工具那本轮就是最后一轮。这样设计的优点是每一轮恰好对应一次模型 API 调用除了工具执行是夹在中间的trace 结构清晰便于分析。三、配套工具edit_file——安全地修改文件在讲更复杂的 Session 之前先简单提一下今天新增的第三个工具edit_file。之前我们有write_file但它的问题在于“整文件覆盖”。如果模型漏掉了一行代码整个文件就坏了。而且在大文件上write_file会消耗大量 token效率极低。所以edit_file的思路是“局部替换”参数-path:文件路径-old_string:要替换的原文必须完全一致-new_string:新文本-replace_all:是否全部替换默认false安全规则文件不存在 → 提示用write_file新建old_string未找到 → 返回文件 preview提示先read_file多处匹配且未设replace_all→ 拒绝执行防止误改old_string new_string→ 拒绝无意义修改edit_file的加入让 Agent 在修改代码时更安全、更精准。但这不是今天的重点重点在下面。四、跨 Run 的多轮Session —— 让 Agent 记住你是谁4.1 之前的问题每次 Run 都是“失忆”的尽管我在单次 Run 里实现了多轮 Turn但每一次npm run dev都是独立的Run 1用户说“我叫小明”Run 2用户说“我叫什么名字” → Agent 不记得了这显然不符合“对话”的直觉。但跨 Run 记忆的设计比我想象中复杂得多。4.2 核心问题到底该存什么一开始我天真地想直接把这次 Run 的整个messages数组存下来下次 Run 直接加载。但很快我就意识到问题messages数组里包含tool_call和tool_result消息这些消息是跟单次 Run 绑定的。如果下次 Run 直接加载这些消息模型的 API 格式会乱掉 —— 因为tool_result必须紧跟在对应的tool_call之后而跨 Run 时上下文已经变了。4.3 我的选择只存文本对话不存工具过程最终我决定只持久化user和assistant的文本消息纯对话而不保存tool_call和tool_result。这样设计的好处跨 Run 恢复时API 格式不会乱。存储体积小工具调用过程通常比对话文本大得多。符合“Session 本质是记忆对话”的直觉。代价是跨 Run 时Agent 会记得“你说了什么”“我回答了什么”但不记得“上次我调了什么工具”“那个工具的返回结果是什么”。但我觉得这个代价是值得的因为工具过程是“过程性”信息而跨 Run 需要的是“结论性”信息。4.4 新增的 Session 模块我新建了src/session/目录src/session/ types.ts # Session 类型定义 SessionStore.ts # 存 sessions/{sessionId}.json _last.json 指针 SessionManager.ts # 创建 / 加载 / 追加 / 清除CLI 用法# 默认无 session与之前一样npm run dev--你好# 新会话会保存供下次 continuenpm run dev----new记住我在学 harness# 接着上次会话npm run dev----continue我刚才学到哪了# 清除上次会话后再跑npm run dev----forget重新开始Trace 新增事件session_loaded加载了多少条历史session_saved本轮结束后写入 sessionsession_clearedforget 或 newtrace JSON 里也会带上sessionId字段方便追溯。4.5 踩坑实录 340 条消息的限制是怎么来的坑点如果不限制 Session 消息数量长期使用后messages会爆掉尤其是大模型有上下文长度限制。选择我设了一个上限——最多保留最近 40 条消息约 20 轮问答。这个数字怎么来的我查了 DeepSeek 的上下文窗口是 64K token40 条消息平均每条 500 token中文差不多 20K token留了足够的余量给工具调用和 system prompt。未来我可以把这个数字做成可配置的但当前阶段够用了。4.6 踩坑实录 4--continue和--new的语义区分坑点用户可能想“继续上次的对话”也可能想“开一个新对话但保留之前的 session 记录”。这两个需求在 CLI 里很容易混淆。设计--continue加载上次 session如果不存在则报错。--new创建一个新 session如果已有 session会生成新的sessionId_last.json指针指向最新的。--forget删除_last.json和对应的 session 文件但保留历史 session 文件。这样用户就可以灵活切换npm run dev----new开始一个新项目讨论# 新 sessionnpm run dev----continue继续刚才的话题# 接着聊npm run dev----forget重新开始# 清空记忆4.7 当前限制坦诚地写出来无交互式 REPL仍然是一次npm run dev一次 run不像 ChatGPT 那样连续对话。这是一个大项目未来可能会做。Session 存储格式还很简陋就是 JSON没有加密没有压缩。跨 run 记忆只限于文本不保存工具调用历史不保存中间推理过程。但这些限制都是故意为之的——我想先跑通最小可用版本再看看真实使用中会遇到什么问题。五、给今天的几个教训做个总结5.1 单次 Run 内的多轮是“水平扩展”跨 Run 的 Session 是“垂直扩展”Intra-run turns解决的是“一个任务需要多个工具配合完成”的问题。Session解决的是“多个任务之间需要上下文连贯”的问题。这两个维度互不干扰但都很重要。5.2 “存什么不存什么”是 Session 设计的第一问题我踩的最大的坑就是一开始想当然地“存全部 messages”导致 API 格式乱掉。后来才意识到跨 Run 记忆应该只存“结论性”信息用户说了什么、模型回了什么不存“过程性”信息工具调用、中间结果。这个原则其实适用于很多场景缓存、日志、同步、持久化……永远要问自己“我存的这个信息在恢复时真的有用吗会不会反而引入错误”5.3 Trace 和 Turn 让 Debug 变得可视化有了 Turn 事件后我在 Trace Report 里可以直接看到Turn 1: 模型调用了 list_files75 files, 13ms Turn 2: 模型调用了 read_file8180 bytes, 1ms Turn 3: 模型基于两次工具结果给出了最终答案以前这些过程是一团黑盒现在被我拆成了透明的、可分析的一步一步。这种“可观测性”是 Agent 系统持续优化的基础。从这次实际运行的数据看turn_context_snapshot记录了每一轮结束后 messages 的完整快照包括 system prompt、user prompt、assistant 的工具调用记录、tool 的返回结果。这对于复盘模型“为什么在第三轮就直接回答了”非常有价值。六、下一步的计划交互式 REPL让 Session 真正变成“连续对话”而不是每次npm run dev。更多工具Excel 操作、数据库查询等。