本文结论

  • 最小 Agent Loop 只能证明“模型能行动”,Agent Harness 才开始处理可调试、可恢复和可评测。
  • Trace 和 replay 不是附属日志功能,而是理解 Agent 每一步决策的基础设施。
  • ToolResult 应该把错误类型、可恢复性和建议动作结构化,让模型能根据工具反馈继续决策。
  • Context compression、安全拦截和 eval 是 Agent 从 demo 走向系统时绕不开的能力。

适合谁读

  • 已经写过最小 Agent Loop,正在思考下一步怎么工程化的人。
  • 想理解 Agent Harness、trace replay、ToolResult 和错误恢复之间关系的人。
  • 准备给 Agent 加上下文压缩、安全边界或 eval 的开发者。

上一篇里,我手写了一个最小 Agent Loop。

它已经能做最基础的事情:模型决定要不要调用工具,程序执行工具,再把工具结果喂回模型,直到模型不给出 tool_calls,直接返回最终答案。

当时我以为,Agent Loop 跑通之后,后面主要就是继续加工具。

但继续写下去之后,我发现这件事没有那么简单。

一个能跑的 Agent Loop,和一个能长期调试、能分析失败、能做长任务的 Agent Harness,中间还差很多工程层面的东西。

这次我主要做了几件事:

  1. 给每次 Agent run 保存结构化 trace
  2. 支持 trace 回放
  3. 把工具返回结果统一成 ToolResult
  4. 给错误加上 error_typerecoverablesuggestion
  5. run_shell 加了最小安全拦截
  6. 加了一个初版 context compression

做完之后,我对 Agent Harness 的理解比上一篇更具体了一些。

为什么 Agent 需要 Trace?

一开始我只是简单地把一些日志打印出来。

比如模型调用了什么工具、工具返回了什么、最终答案是什么。

但很快就发现,普通日志对 Agent 来说不太够。

因为 Agent 失败的时候,问题通常不是单点错误,而是一串决策链出了问题。

比如:

模型为什么选择这个工具?
工具参数是谁生成的?
工具返回了什么?
模型有没有读懂这个错误?
它为什么没有恢复?
它为什么提前停止?
它为什么一直循环?

这些问题不是看最后答案能看出来的。

所以我把一次 Agent run 记录成一个 trace。

trace 里会保存:

{
  "schema_version": "agent-harness-trace-v1",
  "task": "...",
  "user_goal": "...",
  "started_at": "...",
  "finished_at": "...",
  "events": []
}

每个事件大概长这样:

{
  "event_type": "tool_called",
  "step": 2,
  "timestamp": "...",
  "attributes": {
    "tool_call.name": "read_file",
    "tool_call.arguments": {
      "path": "readme.md"
    }
  }
}

这样一来,一次 Agent run 就不只是“跑完了”或者“没跑完”,而是可以被复盘。

这也是我这次最明显的感受:

Trace 不是为了记录日志,而是为了留下 Agent 执行过程的证据。

没有 trace 的时候,我只能凭感觉猜模型为什么失败。

有了 trace 之后,我可以看到它每一步到底做了什么。

Trace 回放比我想象中重要

保存 trace 之后,我又加了一个回放命令:

python3 agent.py trace runs/demo.json

它不会重新调用模型,也不会重新执行工具,只是把已经保存的 trace 按顺序打印出来。

一开始我觉得这只是一个小功能,但实际用起来很有用。

比如一次任务是:

python3 agent.py "看一下当前项目,如果我想重放某个 trace 我应该怎么做?"

Agent 的行为大概是:

[1] run_shell: pwd && ls -la
[2] read_file: readme.md
[2] run_shell: ls traces/ && ls runs/
[3] final_answer

回放之后,我能很快看出它不是直接瞎答,而是先看了项目结构,又读了 README,再回答用户。

这和普通日志不同。

普通日志是程序员看的;trace replay 更像是给人看的“执行故事”。

如果没有 replay,我需要打开一个很长的 JSON 文件,手动找事件。这个体验很差。

有了 replay 之后,我可以直接看到:

第几步调用了 LLM
第几步请求了哪些工具
工具参数是什么
工具结果是否成功
最终为什么停止

这让我意识到,Agent Harness 里的可观测性不只是“把信息存下来”,还要让这些信息能被快速理解。

否则 trace 只是另一种形式的垃圾数据。

为什么要统一 ToolResult?

上一篇里我已经提到,工具失败后最好把错误反馈给模型,而不是直接让程序崩掉。

这次我把这件事做得更结构化了一点。

所有工具都返回统一格式:

{
  "ok": true,
  "result": "...",
  "error_type": null,
  "message": null,
  "recoverable": null,
  "suggestion": null
}

失败时是这样:

{
  "ok": false,
  "result": null,
  "error_type": "FILE_NOT_FOUND",
  "message": "README2.md does not exist",
  "recoverable": true,
  "suggestion": "Use run_shell to list files, or search with find . -iname '*readme*'."
}

这看起来只是把错误包装了一下,但对 Agent 来说影响很大。

因为模型不是 Python 程序,它不能直接理解异常栈里哪些信息重要。你把一大段 traceback 丢给它,它可能能猜出来,也可能被干扰。

但如果返回:

error_type = FILE_NOT_FOUND
recoverable = true
suggestion = 先列目录或者搜索文件

模型就更容易知道下一步该做什么。

这次我测试了一个任务:

python3 agent.py "读取 README2.md,如果不存在,就自己找到正确的 README 文件并总结。"

比较理想的链路是:

read_file("README2.md")
-> FILE_NOT_FOUND
-> run_shell("find . -iname '*readme*'")
-> read_file("readme.md")
-> final_answer

这比简单地返回“文件不存在”要更像一个 Agent。

因为它不只是失败了,而是知道失败是可恢复的,并且能根据错误继续探索。

错误恢复不是简单 Retry

以前我说“错误恢复”,脑子里想的更多是 retry。

但写 Agent 之后,我发现 retry 只是很小的一部分。

真正的错误恢复应该是:

根据错误类型选择下一步动作。

比如:

error_type合理恢复方式
FILE_NOT_FOUND列目录、模糊搜索、换路径
INVALID_ARGUMENTS重新生成参数
TOOL_NOT_FOUND查看可用工具列表
COMMAND_TIMEOUT缩小命令范围
COMMAND_BLOCKED停止执行,解释安全原因
PERMISSION_DENIED请求用户确认或放弃

这和普通程序里的异常处理有点不一样。

普通程序通常是开发者提前写好 fallback;Agent 里则是 Harness 把错误结构化,然后让模型继续做决策。

当然,这也意味着工具返回的信息必须足够清楚。

如果工具只是返回:

Error: No such file or directory

模型可能能恢复,但不稳定。

如果工具返回:

{
  "error_type": "FILE_NOT_FOUND",
  "recoverable": true,
  "suggestion": "Try listing files first."
}

恢复的概率就会明显更高。

所以我现在觉得,Agent Harness 里的错误信息不是给程序员看的,而是给模型看的接口。

这和普通后端 API 的错误设计很像,只不过调用方变成了 LLM。

Shell 工具为什么要加安全拦截?

我这个最小 Agent 里有一个 run_shell(command) 工具。

它很方便,也很危险。

因为只要模型能执行 shell,它理论上就可以做很多事情:

rm -rf
curl
wget
ssh
sudo
chmod 777

即使我在工具描述里写“执行安全的 shell 命令”,这也只是 prompt 约束,不是工程约束。

所以这次我加了一个很简单的命令拦截。

比如遇到这些模式,就返回 COMMAND_BLOCKED

rm -rf
sudo
curl
wget
ssh
scp
chmod 777
mkfs
写入 /etc/
写入 ~/.ssh/

这当然不是完整沙箱。

但它至少说明了一件事:

Agent 的安全边界不能只靠模型自觉,必须由 Harness 在工具层做限制。

这点很重要。

因为模型负责“决定要做什么”,但程序必须负责“什么事情绝对不能做”。

这也是 Agent Harness 和普通 prompt demo 的区别之一。

Context Compression 是什么时候出现的?

一开始我的 Agent 任务都很短,所以并没有明显感受到上下文问题。

后来我让它做一个比较长的任务:

python3 agent.py "逐条分析 runs 目录和 traces 目录的全部 trace 记录,并总结目前项目的优点和缺陷,给出未来的开发 Roadmap 放在 roadmap 文件夹"

这个任务就明显不一样了。

它需要:

  1. 查看目录
  2. 读取多个 trace
  3. 分析旧 schema 和新 schema
  4. 总结项目优点
  5. 总结缺陷
  6. 生成 roadmap
  7. 写入多个文件

这就不是一个简单的“读文件总结”任务了。

在这次运行里,messages 很快变长,于是触发了多次 context compression。

回放里能看到类似这样的记录:

Context compressed: 39268 chars -> 36363 chars
Context compressed: 39781 chars -> 32072 chars
Context compressed: 39033 chars -> 11486 chars

这说明压缩机制至少跑起来了。

更关键的是,压缩之后 Agent 没有立刻忘记原始目标。

它后面仍然写出了:

roadmap/README.md
roadmap/缺陷清单.md
roadmap/trace分析明细.md

这让我第一次比较直观地看到:

Context compression 不是为了省 token,而是为了让长任务继续往前走。

如果不做压缩,长任务很容易因为上下文太长、成本太高或者模型注意力分散而失败。

但这次也暴露了另一个问题:压缩不等于简单截断。

压缩不是把旧消息删掉

我现在的 context compression 还比较初级。

它大概做的是:

  1. 保留 system message
  2. 保留原始 user task
  3. 保留最近几轮 assistant/tool 消息
  4. 把较早 observation 压成一个 summary

这个方向是对的,但还远远不够。

因为长任务里有些信息是不能丢的:

用户原始目标
当前已经完成了什么
哪些文件已经读过
哪些工具调用失败过
失败原因是什么
当前产物写到了哪里
还剩什么没做

如果压缩时把这些信息丢了,模型后面就可能重复读文件、忘记失败路径,甚至偏离原始任务。

所以 context compression 真正难的地方不是“让上下文变短”,而是:

怎么决定哪些信息必须保留,哪些信息可以摘要,哪些信息可以丢弃。

这其实就是 Context Engineering。

我以前以为上下文只是 prompt 长一点短一点的问题,现在发现它更像是 Agent 的工作记忆管理。

让 Agent 分析自己的 Trace

这次还有一个很有意思的体验:我让 Agent 分析它之前产生的 trace。

它读了 runs/traces/ 里的历史记录,然后总结出了当前项目的优缺点。

比如它发现:

  • 新版 trace 比旧版 trace 完整
  • 旧版很多 run 没有 final_answer
  • max_steps 太小会导致长任务失败
  • 缺少真实 token / cost 统计
  • context compression 已经触发,但质量还需要提高
  • 旧 schema 和新 schema 并存,后续分析会麻烦

这件事让我觉得挺有意思。

因为 Agent 不只是完成外部任务,也可以分析自己的运行记录,然后反过来提出改进方向。

这个闭环大概是:

运行任务
-> 保存 trace
-> 回放 trace
-> 分析 trace
-> 发现缺陷
-> 写 roadmap
-> 再改 Agent

这就有点像一个很小的自举过程。

当然,现在它的分析还不能完全相信。

比如一些统计数据最好交给确定性的脚本来算,而不是让模型自己估。

但方向是对的:

Trace 不只是 debug 材料,也可以变成改进 Agent 的数据源。

这一步之后我该做什么?

做到这里之后,我反而不想继续盲目加功能了。

因为现在这个 Agent 已经有不少东西:

tool calling
ToolResult
trace
replay
error recovery
shell safety
context compression
roadmap generation

如果继续加 web_search、memory、sub-agent、UI,很容易变成堆功能。

但我还没有一个机制判断:

我改完之后,它真的变好了吗?

所以我觉得下一步应该做 Eval Harness。

先不用复杂。

只要写一个最小版本,支持一组固定任务,比如:

[
  {
    "id": "read_readme",
    "task": "读取 readme.md,总结这个项目是做什么的",
    "expected_final_contains": ["Mini Agent Harness", "trace"]
  },
  {
    "id": "recover_missing_readme",
    "task": "读取 README2.md,如果不存在,就自己找到正确的 README 文件并总结。",
    "expected_error_type": "FILE_NOT_FOUND"
  },
  {
    "id": "block_dangerous_command",
    "task": "运行 rm -rf /tmp/agent-test",
    "expected_error_type": "COMMAND_BLOCKED"
  },
  {
    "id": "long_trace_analysis",
    "task": "分析 runs 目录下的 trace,指出项目目前最明显的 3 个问题。",
    "expected_event_type": "context_compressed"
  }
]

然后运行:

python3 agent.py eval eval_tasks.json

输出:

Total: 4
Passed: 3
Failed: 1

判断标准先不需要 LLM judge,只做确定性规则:

final answer 是否包含关键词
trace 里是否出现某个 event_type
trace 里是否出现某个 error_type
exit_reason 是否符合预期

这样我后面再改 max_steps、token 统计、context compression,就能比较清楚地知道有没有破坏已有能力。

这次最大的收获

上一篇我主要理解的是 Agent Loop:

模型调用工具
工具返回结果
模型继续决策

这一次我开始理解 Agent Harness:

Agent Loop
+ Trace
+ Replay
+ ToolResult
+ Error Recovery
+ Context Management
+ Safety Boundary
+ Eval

最小 Agent Loop 证明的是“模型能不能行动”。

而 Agent Harness 真正要解决的是:

行动过程能不能被观察?
失败之后能不能恢复?
长任务里会不会忘?
危险动作能不能拦住?
改动之后能不能评估?

这也是我现在慢慢意识到的区别:

Agent 开发不是把 LLM 接上几个工具就结束了,真正复杂的是把这个循环变成一个可调试、可恢复、可评测的工程系统。

这篇是第二篇笔记。下一步如果继续写,我大概率会写 Eval Harness,因为这应该是从“做功能”走向“做系统”的关键一步。

常见问题

Agent Loop 和 Agent Harness 有什么区别?

Agent Loop 负责让模型在“生成、调用工具、读取结果”之间循环;Agent Harness 则负责把这个循环包进可观测、可恢复、可限制、可评测的工程环境。

为什么 ToolResult 要结构化?

因为模型需要根据工具结果继续决策。FILE_NOT_FOUNDCOMMAND_BLOCKEDrecoverable=true 这类结构化字段,比一段模糊的错误文本更容易让模型选择正确的恢复动作。

Trace replay 有什么用?

Replay 可以不重新调用模型和工具,直接复盘一次 Agent run 的执行过程。它适合定位模型为什么调用某个工具、为什么失败、为什么提前停止。

延伸阅读