"Seeing Like an Agent" 深度解读
March 23, 2026 · Tech Blog
原文:Thariq (@trq212),Anthropic Claude Code 团队 解读基于对原文的逐概念拆解、追问与延伸讨论
核心论点:Action Space 的设计哲学
Action space 是模型在每一轮能做的所有事情的集合——tools 数组里的所有工具加上输出自由文本。整篇文章的每一个故事都在调整这个集合的形状:
- AskUserQuestion:往里加一个选项
- Todo → Task:替换一个选项
- Progressive Disclosure:在不加选项的前提下扩展能力
- Mode 切换:在不同阶段暴露不同的子集
Thariq 的类比很精准:给你一道难数学题,纸笔、计算器、电脑各自对应不同能力水平的使用者。工具要和使用者的能力匹配——这就是 "see like an agent" 的含义。
第一课:AskUserQuestion 的三次迭代
前置概念:什么是 Harness
Harness 的原意是马具。马是动力来源但不知道该往哪走,harness 不提供动力,做的事情是把马的力量引导到正确的方向上。
对应到代码层面,harness 不是一个 class,是一个 while loop 加上周围的胶水逻辑:
messages = [{"role": "user", "content": user_input}]
while True:
response = claude_api.call(messages=messages, tools=get_tools(mode))
if response.type == "text":
print(response.text)
break
if response.type == "tool_call":
result = execute(response.tool, response.params)
messages.append(tool_result(result))
continue
每一次循环就是一轮 API 调用。模型本身没有持久状态,所谓的 "模式" 是 harness 里的一个变量,决定了这轮给模型什么 system prompt 和暴露哪些 tools。
前置概念:ExitPlanTool 为什么是一个 Tool
直觉上 "退出计划模式" 是内部状态切换,为什么要做成 tool call?
两个原因。第一,agent loop 的每一轮是独立的 API 调用,模型没有内部状态。"plan mode" 和 "execution mode" 是 harness 在维持的,harness 需要一个明确的信号来判断模型是否规划完毕。自由文本做不了这件事,但 tool call 是一个离散的、确定的事件。
第二,prompt caching。ExitPlanTool 的调用位置可以作为 cache breakpoint。后续 execution 阶段的每一轮 API 调用,断点之前的所有上下文都可以命中缓存,不需要重新计算 KV 向量。这直接影响延迟和成本。
Attempt #1:把 questions 塞进 ExitPlanTool
当时 plan mode 下模型能做的事情只有两种:输出自由文本继续思考,或调 ExitPlanTool 退出规划。没有独立的提问工具。ExitPlanTool 是当时唯一有结构化输出的 hook(tool call 天然是 JSON schema 约束的,harness 可以可靠地拦截并处理),所以他们直接在这个工具的 schema 里加了 questions 字段。
冲突的本质是控制流层面的。 Questions 的存在意味着规划还没结束,而 ExitPlan 的语义是规划已经结束。一个 tool call 是 agent loop 里的一个原子动作,触发 harness 的一个分支处理。两个需要不同处理流程的意图被塞进同一个原子动作,harness 不知道该走哪个分支。
Harness 收到调用:该先展示 questions 等用户回答,还是直接按 plan 执行?退不退出 plan mode?如果退了,questions 的回答去哪处理?如果不退,这个工具为什么叫 Exit?
正确的时序应该是: plan 阶段发现信息不够 → 提问 → 拿到回答 → 继续 plan → 信息够了 → 调 ExitPlanTool 结束。问题发生在 plan 的中间,exit 发生在 plan 的末尾,它们不在同一个时间点上。
Attempt #2:让模型在自由文本里输出结构化格式
让模型在 markdown 里按特定格式输出问题列表,前端解析渲染。问题是模型不稳定——会加过渡句、省略选项、换格式。
暴露了一个基本原则:凡是需要被程序可靠消费的输出,都应该走 tool call 的结构化参数。 Tool call 的 JSON schema 天然是约束空间,模型填参数时知道自己在做结构化输出。自由文本没有这个约束。
Attempt #3:独立的 AskUserQuestion Tool
最终方案:做成独立工具,模型在 plan mode 的任意时刻都可以调用。调用后 harness 弹 modal 阻塞 agent loop,用户回答注入 messages,模型继续规划。
代码层面就是 tools 数组里多一个 dict,harness loop 里多一个 if 分支:
if response.tool == "AskUserQuestion":
answer = show_modal(response.params) # 阻塞等用户回答
messages.append(tool_result(answer))
continue # 不切状态,继续 plan mode
if response.tool == "ExitPlanTool":
mode = "execution" # 切状态
messages.append(tool_result(response.params["plan"]))
continue
每个时刻模型只承载一个意图。需要信息调 AskUserQuestion,想好了调 ExitPlanTool。
教训:解耦分模块才是最好的路。当一个组件承载多重职责导致行为不可预测时,正确的做法不是加更多约束,而是拆成各自语义明确的独立单元。
第二课:Todo → Task 的演进
阶段一:TodoWrite + 系统提醒
早期模型在长任务里会 "忘记" 目标。跑了十几轮 tool call 后,前面的需求和 plan 被中间结果淹没。解法是 TodoWrite 工具让模型建 checklist,加上 harness 每 5 轮注入系统提醒。
阶段二:提醒变成约束
模型变强后,不需要被提醒了。但系统提醒还在,效果反转:模型把 todo list 当成不可违背的指令。执行到一半发现更好的方案,但每 5 轮的提醒把原始 list 塞回来,模型放弃自己更好的判断。
为弱模型设计的 scaffolding,在强模型身上会变成 ceiling。
阶段三:Task 系统
Opus 4.5 的 subagent 能力上来后,TodoWrite 的单线程 checklist 不够用了。Task 系统的关键区别:有依赖关系、跨 subagent 状态共享、可修改删除。
工具的职责从记忆辅助变成了通信协议。
延伸:Mode 切换 vs Subagent
Mode 切换是同一个 loop、同一个 messages 数组,通过切换 system prompt 和可用 tools 来约束行为。没有上下文隔离。
Subagent 是全新的 messages、全新的 loop。主 agent 只把任务描述传给子 agent,拿回最终结果。本质是上下文压缩——30 轮子 agent 执行细节被压缩成一句结论。
这也是 Todo 系统不够用的原因:多个 subagent 各自有独立的 messages,看不到彼此上下文,需要共享的 Task 数据结构来协调。
第三课:搜索设计与 Progressive Disclosure
这是一条连续的演进线,主题是上下文构建的控制权不断下放。
RAG → Grep:从被动接收到主动搜索
最早用向量数据库检索代码片段,harness 预先喂给模型。深层问题是 harness 在替模型决定 "你需要看什么",但 harness 不知道模型推理链条上真正缺什么信息。
给模型 Grep 工具后,模型自己决定搜什么、看什么、要不要继续。前提是模型变强了——早期模型给 grep 也不知道搜什么。
Skills 文件:Progressive Disclosure 的雏形
项目根目录放 SKILL.md,包含高层说明和文件引用。模型按需逐层深入:SKILL.md → auth/README.md → auth/refresh.ts。信息不是一股脑塞给模型,而是模型自己决定要不要深入。
Guide Subagent:终极形态
用户问 Claude Code 关于自身的问题(如何加 MCP、slash command 用法),这些信息塞 system prompt 会造成 context rot——无关信息挤占有用信息的注意力权重。
让模型自己用 progressive disclosure 读文档,又会把整份文档加载进上下文。
最终方案:专门的 subagent 负责搜索文档,返回精确答案。主 agent 上下文里只多一个问答对。
Progressive Disclosure 的局限
四个缺点,各有不同的缓解策略:
Latency 不可预测。 模型自己决定深入几层,无法提前预估。缓解方式是设递归深度上限或 token budget,但限制越死越像 RAG。
模型走错路径。 引用链有多条分支,模型可能选错。缓解方式是在每层入口写好摘要帮助模型判断,本质是信息架构的质量问题。
信息结构维护成本。 引用链断裂、抽象粒度不合理都会破坏整个链条。只能通过自动化(如 CI 检查引用完整性)降低负担。
上下文膨胀。 Progressive disclosure 推迟了膨胀的时间点,但没有消除膨胀本身。Subagent 是更彻底的方案——膨胀发生在隔离的子上下文里。
延伸概念:Token Schema 膨胀
Tools 数组里的工具定义本身占 token。每个工具几十到几百 token,Claude Code 约 20 个工具,光定义可能几千 token。MCP 场景下外部工具的 schema 往往更冗长。
工具定义必须在每轮 API 调用时完整传入,无法 "按需加载"——模型不知道有什么工具就不会调它。Progressive disclosure 解决了内容层面的膨胀,但 schema 层面的膨胀它解决不了。
应对方案有几种思路:
RAG over tools。 大量工具的 schema 存向量库,按需检索注入。
分类分层。 先给模型工具目录,选类别后注入具体工具。
Skills 化。 Schema 只保留最小描述,详细使用说明写在文件里按需读取。
Meta-tool。 一个工具入口,参数是自然语言,harness 侧路由到具体实现。Bash 是唯一真正 make sense 的 meta-tool——操作系统是经过几十年打磨的路由层,且所有标准命令的用法已经在 LLM 的训练数据里了,不需要在 tools 数组里再写一遍。自定义工具做不到这一点。
Prompt Caching 机制
Prompt caching 是 API 层的优化,不是模型内部的。在请求的 message block 上加 cache_control: { type: "ephemeral" } 字段设置断点。断点之前的内容如果与上次请求一致,KV 向量直接复用,不重新计算。
ephemeral 表示缓存存活约 5 分钟,每次命中刷新倒计时,恰好匹配 agent loop 的使用模式。
断点放置策略的关键原则是:放在变化频率最低、体积最大的位置。 System prompt 是最基础的,但 agent loop 里可以更激进——断点可以动态推进到 messages 数组的尾部。
while True:
for msg in messages:
msg.pop("cache_control", None)
messages[-1]["cache_control"] = {"type": "ephemeral"}
response = claude_api.call(messages=messages)
messages.append(new_tool_result)
每轮结束时断点在最后一条 message 上,下一轮只需增量计算新增内容。
总结:这是一门手艺,不是一套规则
文章的最后一句话是关键的:工具设计是 art 而不是 science。它取决于模型能力、agent 目标和运行环境的组合。
但从所有案例中可以提炼出几个不变的原则:
- 一个工具一个意图。 Tool call 是 agent loop 的原子动作,承载多个意图会造成控制流歧义。
- 工具跟着模型能力迭代。 为弱模型设计的 scaffolding 会成为强模型的 ceiling。
- 能不加工具就不加。 每个工具都是 schema token 和模型决策分支的成本。Progressive disclosure 是扩展能力而不增加工具的首选路径。
- 上下文控制权逐步下放。 从 harness 预喂,到模型自己搜索,到 subagent 隔离搜索——方向始终是让模型按需获取信息。
- 观察模型的实际行为。 不是你觉得工具该怎么用,而是模型实际上怎么用。再好的设计,模型不理解就是废的。