"Seeing Like an Agent" 深度解读

March 23, 2026 · Tech Blog

原文:Thariq (@trq212),Anthropic Claude Code 团队 解读基于对原文的逐概念拆解、追问与延伸讨论

https://x.com/trq212/article/2027463795355095314


核心论点: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 目标和运行环境的组合。

但从所有案例中可以提炼出几个不变的原则:

  1. 一个工具一个意图。 Tool call 是 agent loop 的原子动作,承载多个意图会造成控制流歧义。
  2. 工具跟着模型能力迭代。 为弱模型设计的 scaffolding 会成为强模型的 ceiling。
  3. 能不加工具就不加。 每个工具都是 schema token 和模型决策分支的成本。Progressive disclosure 是扩展能力而不增加工具的首选路径。
  4. 上下文控制权逐步下放。 从 harness 预喂,到模型自己搜索,到 subagent 隔离搜索——方向始终是让模型按需获取信息。
  5. 观察模型的实际行为。 不是你觉得工具该怎么用,而是模型实际上怎么用。再好的设计,模型不理解就是废的。