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

系列博客第四篇 · 2026-07-01
今天的主角是“多轮”——两种不同层次的多轮


一、前情回顾

三天时间,我的 Mini Agent harness 走过了这样一条路:

  • Day 1:最小闭环跑通(用户输入 → 模型 → 工具 → 输出)。
  • Day 2:Context Builder 统一上下文,Trace Report 让 JSON 变可读。
  • Day 3:Trace 事件细化(context_built/engine_started),新增 search_web/run_shell 工具,以及自动错误恢复(Error Recovery)

今天(Day 4)的核心主题是 “多轮”

这里的“多轮”其实包含两个完全不同的层次:

  1. 单次 Run 内的多轮(Intra-run turns):一次 npm run dev 执行过程中,模型和工具可能来回交互很多次,最终才给出答案。
  2. 跨 Run 的多轮(Session):你退出程序、下次再启动,Agent 还能记得上次聊过什么。

这两个层次我都在今天实现了,而且都踩了不小的坑。这篇文章会重点讲这两个部分的设计思路和踩坑过程,附带简单提一下今天新增的第三个工具 edit_file


二、单次 Run 内的多轮:Turn Tracking

2.1 之前的问题:一次 Run 只有一轮

之前的架构是这样的:用户输入 → 模型决定调用工具 → 工具执行 → 模型生成最终答案 → 结束。

看起来没问题,对吧?但实际情况中,模型经常需要多次调用工具才能完成一个任务

举个例子,用户说:

“列出根目录文件,然后读取 README.md 前 20 行。”

模型可能需要:

  1. 先调用 list_files 获取根目录文件列表。
  2. 再调用 read_file 读取 README.md 的内容。
  3. 最后根据两次工具的结果,整理成表格输出。

但之前我的系统在第一次工具调用后就默认进入“结束”流程,根本没给模型第二次调用工具的机会。这是一个严重的逻辑缺陷。

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 个 Turn:

Turn 1:模型决定先调用 list_files 观察环境。

turn_started (turn=1, messages=2)
  -> turn_model_response (tools=1)
  -> tool_call list_files
  -> tool_result ok (75 files, 13ms)
  -> turn_context_snapshot (messages=4)
  -> turn_done (reason: tool_observations)

Turn 2:模型拿到文件列表后,决定读取 README.md

turn_started (turn=2, messages=4)
  -> turn_model_response (tools=1)
  -> tool_call read_file
  -> tool_result ok (8180 bytes, 1ms)
  -> turn_context_snapshot (messages=6)
  -> turn_done (reason: tool_observations)

Turn 3:模型拿到 README 内容后,认为信息足够了,直接生成最终回答。

turn_started (turn=3, messages=6)
  -> turn_model_response (tools=0, assistantPreview 开始输出)
  -> turn_done (reason: final_answer, finished=true)
  -> text_delta (输出答案)
  -> done

最终输出:模型整理了一份清晰的 Markdown 表格,包含根目录关键文件列表和 README 前 20 行内容。

整个过程用时约 11 秒,其中工具执行只花了 14mslist_files 13ms + read_file 1ms),其余时间都在等模型推理。这说明多轮对话的瓶颈在模型推理速度,而不在工具执行

对应的 Trace Report 自动生成了「多轮执行(Turn)」章节:

- Turn 1:tools=1 (list_files) | finished=false | reason=tool_observations
- Turn 2:tools=1 (read_file) | finished=false | reason=tool_observations
- Turn 3:tools=0 | finished=true | reason=final_answer

一眼就能看出这次 run 是怎么一步步完成的。

2.4 踩坑实录 1:模型“撒谎”说它完成了

坑点:模型有时候会在 turn_model_response 里说“我已经完成了任务”,但实际上它根本没有调用任何工具,或者工具还没执行完。

原因:DeepSeek 的 finish_reason 有时候是 stop,但模型只是暂停思考,并没有真正给出最终答案。它可能在想“我还需要再调用一个工具,但我先暂停思考”。

解法:我在 turn_done 的判断逻辑里增加了一条规则:如果本轮模型没有调用任何工具,但 finish_reasonstop,不要立刻标记为 final_answer,而是把模型输出作为 text_delta 流式返回,然后让用户决定是否继续。 但仔细一想,这会把交互逻辑推给用户,违背了“自动化”的初衷。最终我的做法是:如果模型没有调用工具,且 finish_reasonstop,就当作 final_answer 处理,但在 trace 里标记为 early_stop,方便后续分析模型行为。

这个坑让我意识到,模型的行为并不总是符合直觉的,你需要在实际运行中不断调整对“完成”的判断标准

2.5 踩坑实录 2:Turn 的边界在哪里?

坑点turn_startedturn_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_calltool_result 消息,这些消息是跟单次 Run 绑定的。

如果下次 Run 直接加载这些消息,模型的 API 格式会乱掉 —— 因为 tool_result 必须紧跟在对应的 tool_call 之后,而跨 Run 时上下文已经变了。

4.3 我的选择:只存文本对话,不存工具过程

最终我决定只持久化 userassistant 的文本消息(纯对话),而不保存 tool_calltool_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 -- "你好"

# 新会话(会保存,供下次 continue)
npm run dev -- --new "记住:我在学 harness"

# 接着上次会话
npm run dev -- --continue "我刚才学到哪了?"

# 清除上次会话后再跑
npm run dev -- --forget "重新开始"

Trace 新增事件

  • session_loaded:加载了多少条历史
  • session_saved:本轮结束后写入 session
  • session_cleared:forget 或 new

trace JSON 里也会带上 sessionId 字段,方便追溯。

4.5 踩坑实录 3:40 条消息的限制是怎么来的?

坑点:如果不限制 Session 消息数量,长期使用后 messages 会爆掉(尤其是大模型有上下文长度限制)。

选择:我设了一个上限——最多保留最近 40 条消息(约 20 轮问答)

这个数字怎么来的?我查了 DeepSeek 的上下文窗口是 64K token,40 条消息平均每条 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 "开始一个新项目讨论"   # 新 session
npm 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_files(75 files, 13ms)
Turn 2: 模型调用了 read_file(8180 bytes, 1ms)
Turn 3: 模型基于两次工具结果,给出了最终答案

以前这些过程是一团黑盒,现在被我拆成了透明的、可分析的一步一步。这种“可观测性”是 Agent 系统持续优化的基础。

从这次实际运行的数据看,turn_context_snapshot 记录了每一轮结束后 messages 的完整快照,包括 system prompt、user prompt、assistant 的工具调用记录、tool 的返回结果。这对于复盘模型“为什么在第三轮就直接回答了”非常有价值。


六、下一步的计划

  • 交互式 REPL:让 Session 真正变成“连续对话”,而不是每次 npm run dev
  • 更多工具:Excel 操作、数据库查询等。

更多推荐