Agent 的本质:LLM + Tool Loop
一句话结论(30s)
Agent 不是「一次对话」,而是 LLM 反复调用工具、直到满足停止条件才退出的循环。这个循环由三个环节组成:think(模型决定下一步)→ act(运行时执行工具)→ observe(把结果喂回模型),即 ReAct 范式。工程上的真正难点不在循环本身,而在三件事:循环卡死(重复调同一个工具)、token 爆炸(每轮重发全部历史)、结果格式化(工具输出怎么喂回去才不污染上下文)。理解了这三个问题,你就理解了为什么「循环是免费的,工程是昂贵的」。
一、Agent 为什么不是「一次对话」
先看一个普通 LLM 调用:
messages = [{"role": "user", "content": "帮我查一下今天深圳天气"}]
resp = llm.chat(messages) # 一次往返
answer = resp.content # 模型直接输出文本
模型只能「凭记忆回答」。如果它不知道深圳今天的天气,就只能编一个——因为它的知识被冻结在训练截止日期,且没有任何能力去访问外部世界。
Agent 的解法是:给模型一个「工具调用」的输出通道。模型不再只能输出文本,还能输出一个结构化的「我要调用这个函数」的意图:
{
"role": "assistant",
"content": null,
"tool_calls": [
{"id": "call_1", "name": "get_weather", "arguments": "{\"city\": \"深圳\"}"}
]
}
运行时(Runtime)收到这个意图后,去真实执行 get_weather("深圳"),拿到 "26°C,晴",再作为一条新消息追加回去:
{"role": "tool", "tool_call_id": "call_1", "content": "26°C,晴"}
然后重新请求模型。这一轮模型看到真实数据后,才输出最终答案「深圳今天 26°C,晴」。
关键洞察就在这里:「调用工具」和「最终回答」不是两种模式,而是同一次生成的不同产物。模型的输出空间被扩展了——它可以输出文字,也可以输出 tool_calls。运行时只需要判断「这次输出里有没有 tool_calls」:有就执行工具、喂回结果、再来一轮;没有就结束。
于是 Agent 的最小实现只有十几行:
def run_agent(user_query, tools, max_iter=20):
messages = [{"role": "user", "content": user_query}]
for _ in range(max_iter): # ← 循环骨架
resp = llm.chat(messages, tools=tools)
if not resp.tool_calls: # 停止条件:没有工具调用
return resp.content # 这就是最终答案
for call in resp.tool_calls: # act:执行每个工具
fn = TOOL_REGISTRY[call.name]
result = fn(**json.loads(call.arguments))
messages.append({"role": "assistant", "content": resp.content,
"tool_calls": [call]})
messages.append({"role": "tool",
"tool_call_id": call.id,
"content": str(result)}) # observe:喂回结果
raise MaxIterationError("循环未收敛") # 兜底:迭代上限
这就是一个完整 Agent 的全部骨架。真正的复杂度,都藏在这个 for 循环的工程细节里。
💭 思考:为什么 Agent 的最小实现能短到十几行,真正值钱的却在这十几行之外?—— 一步步推:骨架的职责只有三个——请求模型、看有没有 tool_calls、有就执行并喂回、没有就返回。这十几行是对「循环」这件事的忠实表达,所以短。但一旦放进真实环境,「循环不能无限跑」的约束立刻逼出三层护栏(硬上限、去重、纠偏),「每轮重发历史」逼出 token 预算,工具返回脏数据逼出格式化层。换句话说,骨架短是因为它只描述了「正确的路径」,而工程贵在描述「错误会从哪冒出来、怎么兜住」。如果不加这些,一个绕圈的模型就能把你的账单烧穿——所以「循环是免费的,工程是昂贵的」。
这套十几行的骨架,正是 Claude Code 主循环的最小版本。它的主循环落在 src/query.ts(QueryEngine 导入 query 函数并 for await 逐条消费其产出,负责驱动与兜底):每轮请求模型、解析出 tool_use 块,只要有工具调用就执行工具、把结果追加回历史、进入下一圈;没有工具调用就返回最终答案。源码里把停止条件写得非常直白——循环入口声明 let needsFollowUp = false,流式响应里每出现一个 tool_use 块就把它置为 true;一轮流结束后 if (!needsFollowUp) 是唯一的收尾判断(源码注释原话:the sole loop-exit signal)。换言之,你的十几行 Python 和它的差别不在骨架,而在骨架外面套的护栏和可观测性。
二、Tool Loop 的三环节:think → act → observe
ReAct 范式(Yao et al. 2022)把上述循环拆成三个语义明确的环节,每一圈都走一遍。它证明:把「思考」和「行动」交错写进上下文,模型在知识问答、决策类任务上的正确率和可解释性都显著优于纯 Chain-of-Thought 或纯 Act。
| 环节 | 谁在做 | 做什么 | 产物 |
|---|---|---|---|
| think | 模型 | 看当前上下文,决定下一步动作 | 一段推理 + 一个 tool_call(或最终答案) |
| act | 运行时 | 执行模型指定的工具 | 工具的真实返回值 |
| observe | 运行时 + 模型 | 把返回值格式化后追加回上下文,供下一轮 think | 一条 role: tool 消息 |
一圈的时序如下:
用户: "帮我查深圳天气并判断要不要带伞"
│
▼
┌─────────────────────────────────────────────────┐
│ 第 1 圈 │
│ THINK 模型: "需要查天气 → 调 get_weather" │
│ ACT 运行时: get_weather("深圳") → "26°C,晴" │
│ OBSERVE 追加 tool 消息,重新请求模型 │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 第 2 圈 │
│ THINK 模型: "26°C 不用带伞 → 直接回答" │
│ 输出不带 tool_calls 的文本 │
│ → 满足停止条件,循环退出,返回最终答案 │
└─────────────────────────────────────────────────┘
注意一个反直觉的点:think 环节的「推理」未必以独立文本形式存在。早期 ReAct 要求模型先输出 Thought: ... 再输出 Action: ...,用纯文本来驱动循环;现代实现(OpenAI function calling、Anthropic tool use)把「行动意图」变成了结构化字段 tool_calls,而「思考」则隐含在模型生成这些字段之前的内部推理里,不再强制暴露。但三个环节的职责没变——只是 act 从「解析文本里的 Action」变成了「解析结构化字段」。
这也是为什么「工具定义」要写得像文档:name、description、parameters(JSON Schema)是模型在 think 阶段唯一能看到的关于工具的「说明书」。description 写得含糊,模型就会在 think 阶段调错工具或编造参数。
三、关键工程问题
3.1 循环卡死:重复调同一个工具
最常见的故障是模型陷入死循环——反复调用同一个工具、用同一组参数,期望得到不同结果:
第 3 圈: get_weather("深圳") → "26°C,晴"
第 4 圈: get_weather("深圳") → "26°C,晴" ← 结果没变,模型还调
第 5 圈: get_weather("深圳") → "26°C,晴" ← 死循环
根因通常是:模型不理解工具结果,或认为「多调一次能拿到新信息」。工程上必须加护栏,而不是依赖模型自律:
def run_agent(user_query, tools, max_iter=20, repeat_threshold=3):
messages = [{"role": "user", "content": user_query}]
seen = {} # (tool_name, arguments) -> 调用次数
for step in range(max_iter):
resp = llm.chat(messages, tools=tools)
if not resp.tool_calls:
return resp.content
for call in resp.tool_calls:
key = (call.name, call.arguments)
seen[key] = seen.get(key, 0) + 1
if seen[key] > repeat_threshold:
# 强制注入一条提示,逼模型换策略或收尾
messages.append({"role": "system",
"content": f"已连续 {repeat_threshold} 次调用 "
f"{call.name} 且参数相同、结果未变,"
f"请基于已有信息给出结论或换用其他工具。"})
continue
result = TOOL_REGISTRY[call.name](**json.loads(call.arguments))
messages.append({"role": "assistant", "tool_calls": [call]})
messages.append({"role": "tool",
"tool_call_id": call.id,
"content": str(result)})
raise MaxIterationError(f"{max_iter} 轮未收敛")
三层护栏缺一不可:
max_iter硬上限——无论什么情况都要能强制退出,这是最后的兜底;- 重复调用检测——
(tool_name, args)作为 key 计数,超阈值就打断; - 注入纠偏提示——不是简单 abort,而是给模型一个「你已经在绕圈了」的信号,让它有机会优雅收尾。
这三层护栏在真实产品里都有对应:max_iter 硬上限,就是 Claude Code 的 maxTurns——src/query.ts 里 if (maxTurns && nextTurnCount > maxTurns) 直接返回一个 max_turns_reached 附件,QueryEngine 再把它翻译成 error_max_turns 错误;外加一个 maxBudgetUsd 预算上限,src/QueryEngine.ts 里 if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) 一超就返回 error_max_budget_usd。这两个开关「无论模型绕不绕圈」都一定会触发,是最终兜底。值得玩味的是,(tool, args) 去重这种细粒度的「重复调用检测」,在 Claude Code 主循环里反而没有显式实现——它防卡死主要靠 turn 上限 + 预算上限,再叠加可恢复错误的重试次数上限:例如输出 token 超限后的恢复循环,src/query.ts 里硬编码了 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3,最多重试 3 次。这印证了「护栏是工程,不是模型自律」:产品更愿意用无差别的硬上限兜底,而不是逐工具计数。
💭 思考:为什么防卡死必须靠「护栏」,而不是靠「模型自律」?—— 反推:模型陷入死循环,恰恰是因为它在 think 阶段「相信」再调一次能拿到新信息——它已经在自律了,自律得出的结论是「继续调」。所以指望模型自己停,等于指望一个以为自己没绕圈的人承认自己在绕圈。于是约束就落到运行时这一侧:你必须能在模型之外观察到「它在绕圈」,并用无差别的硬上限(max_iter、预算)兜底——因为它们不依赖模型「听不听话」,无论绕不绕都触发。这也解释了为什么 Claude Code 用 turn 上限 + 预算上限,而不是逐工具计数:逐工具计数要维护状态、要判断「什么叫参数相同」,成本高;而硬上限对任何死循环都一视同仁、必然生效。护栏的本质,是把「正确性」从概率事件(模型自律)变成确定事件(运行时强制)。
3.2 Token 爆炸:每轮都在重发历史
Tool Loop 的致命代价是:每一轮都要把从第一条 user 消息到当前的全部历史重新发给模型。上下文是自回归的,模型没有「增量记忆」,它只能靠重读历史来维持状态。
量化一下:假设单轮对话平均 N token,循环跑 M 轮,则总 token 消耗是:
总 token ≈ N × M(历史重发) + 每轮新增的工具结果
也就是 O(M × N) 的线性膨胀。一个只查一次天气的简单任务,实际账单可能是普通单轮对话的 5-10 倍。工具返回一个 10KB 的 JSON(比如查了一次日志、读了一个大文件),这个 10KB 会在之后每一轮都被重发一遍。
缓解手段按优先级排:
| 手段 | 思路 | 落地 |
|---|---|---|
| 结果裁剪 | observe 阶段就截断,别让脏数据进上下文 | 工具结果先 truncate 到预算内再喂回 |
| 上下文压缩 | 循环过程中把旧历史总结成摘要 | Claude Code 的 /compact 与自动压缩,压成摘要再续跑 |
| prompt caching | 不变的前缀只算一次钱 | Anthropic 的 cache_control、OpenAI 的 prompt caching |
| 子任务隔离 | 把大任务拆成多个独立循环,避免单个循环无限膨胀 | 多 Agent / 子循环各带独立上下文 |
其中结果裁剪性价比最高,却最常被忽视。工具函数的返回值不是「原样」塞回去就完事——这是下一节的内容。
自动压缩也不是「把历史删掉」那么粗。OpenAI Codex 的 compact 模块(codex-rs/core/src/compact.rs)把它落成一种叫 Memento 的策略(分析埋点里记作 CompactionStrategy::Memento):把旧历史压成一段带 SUMMARY_PREFIX 前缀的摘要(format!("{SUMMARY_PREFIX}\n{summary_suffix}")),同时从尾部倒序保留最近若干条用户消息——build_compacted_history_with_limit 用 user_messages.iter().rev() 倒推,approx_token_count 估算每条长度,truncate_text 按 token 截断,总预算硬控在 COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000(20,000 token)以内,超出部分截断;压缩完还会硬编码弹一条警告(WarningEvent):「长线程、多次压缩会降低模型准确度,能开新线程就开新线程」。所以压缩是 token 爆炸的止损阀,但它自己也有精度代价——不是免费的。
💭 思考:为什么 token 爆炸的第一优先是「结果裁剪」,而不是更高级的压缩或缓存?—— 从成本与位置反推:爆炸的源头是「工具返回的脏数据被原样塞进历史,然后每轮重发」。结果裁剪发生在 observe 环节、也就是数据「刚进门」的那一步——在源头截断,等于后面每一轮重发省下的都是同一份。而压缩是「事后止损」,缓存是「不变前缀只算一次钱」,它们各有代价:压缩有精度损失(Codex 的 Memento 自己都弹警告),缓存只能省不变部分。所以按「性价比 = 省下的 token / 引入的复杂度」排,源头截断第一。如果不先裁剪,一个 2MB 的 read_file 结果会在之后每一轮都被重发一遍——后面的压缩和缓存都得先替它扛这个锅。
3.3 工具结果如何格式化
observe 环节最容易被写成 content: str(result) 一行带过,但它是整个循环里最脏的环节。三种典型坏情况:
① 结果太长——grep 返回 5000 行、read_file 读了 2MB 文件。原样塞回去会瞬间撑爆上下文,把模型注意力冲散。
def observe(result, budget=4000):
text = json.dumps(result, ensure_ascii=False) if not isinstance(result, str) else result
if len(text) > budget:
return text[:budget] + f"\n...[已截断,共 {len(text)} 字符]"
return text
② 结果报错——工具执行抛异常(网络超时、权限不足)。如果直接 except 吞掉,模型会以为调用成功了,基于一个空结果继续推理,越跑越偏。正确做法是把错误也转成结构化文本喂回去:
try:
result = fn(**args)
content = {"ok": True, "data": result}
except Exception as e:
content = {"ok": False, "error": f"{type(e).__name__}: {e}"}
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(content, ensure_ascii=False)})
让模型看到 "ok": false,它才能在下一轮 think 里做出「换一个工具」或「告诉用户失败了」的正确决策。
③ 结果不是给模型看的——工具返回了一个中间产物(比如一个文件的路径、一个临时 ID),模型不需要全文,只需要一个「引用句柄」。这时 observe 应该返回「摘要 + 句柄」,而不是把原始对象塞进去。
一句话总结:observe 环节的产物不是「工具结果」,而是「喂给模型的下一条 prompt」。它需要像写 prompt 一样精心裁剪、标注状态、控制长度。
这套「裁剪 + 标状态 + 控长度」在 Claude Code 里落成了一个专门的工具结果存储层:src/constants/toolLimits.ts 里定义了 DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000(默认 50,000 字符)作为单结果上限,每个工具声明自己的 maxResultSizeChars——GrepTool 20,000、BashTool 30,000、AgentTool 100,000,而读文件的 FileReadTool 直接声明 Infinity(永不落盘,否则会形成 Read→文件→Read 的死环);消息层再套一个 applyToolResultBudget(src/utils/toolResultStorage.ts)的「每消息聚合预算」(MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000)。真正的大结果不是截断,而是「落盘换句柄」——把超大的工具结果持久化到磁盘,只把 PREVIEW_SIZE_BYTES = 2000(前 2000 字节)的预览(Preview (first ...):)和文件句柄喂回模型,模型需要时再用 Read 工具去读全文。这正是③「摘要 + 句柄」的产品级实现。
💭 思考:为什么 observe 的产物不能是「工具结果」,而必须是「下一条 prompt」?—— 从「谁在消费这份产物」反推:工具结果是给运行时看的,但 observe 喂回去的对象是模型——模型要在下一轮 think 里基于它做决策。所以这份产物要满足的是「模型读起来最容易做对决策」:太长会冲散注意力(要截断)、报错要标状态(让模型知道失败了,而不是基于空结果继续推理)、中间产物只给句柄(模型要的是引用不是全文)。如果不这么设计——比如异常被
except吞掉——模型会以为调用成功,基于一个空结果越跑越偏。所以 observe 不是「转发」,是「翻译 + 裁剪 + 标注」,它写的是 prompt,不是日志。
四、从裸循环到工程化
4.1 可观测性:给循环每一圈埋探针
前面三节的护栏,都建立在一个前提上:你能看到循环里发生了什么。但裸 for 循环是个黑盒——Agent 跑完 20 圈,你只看到最终答案,中间哪一圈绕了路、哪个工具把方向带偏了,完全无从追溯。所以工程化的第一步不是写护栏,而是先让每一圈变得可见。
这正是 Claude Code 的 hooks(钩子) 系统在解决的问题。它允许你在工具调用的前后挂自定义脚本,其中最相关的两个是:
PreToolUse:在每次工具调用之前触发。你可以在脚本里检查这次调用是否危险、是否重复,甚至直接拒绝它——这就是「人工确认」闸门(permission prompt)的底层机制:一个写文件、发请求、删代码的动作,可以在 act 之前拦下来问一句「允许吗?」。PostToolUse:在每次工具调用之后触发。它对应的是 act 环节的探针——把「第几圈、调了哪个工具、用了什么参数、是否成功、花了多久」追加到一条日志流里。
对照循环结构看得很清楚:PreToolUse 是 act 的闸门,PostToolUse 是 act 的记录。有了这两者,一次 Agent 运行就从「只有最终答案」变成了「可以逐圈回放」:
{"tool": "Read", "args": {"file_path": "..."}, "ok": true, "latency_ms": 120}
{"tool": "Grep", "args": {"pattern": "..."}, "ok": true, "latency_ms": 45}
{"tool": "Grep", "args": {"pattern": "..."}, "ok": true, "latency_ms": 43}
{"tool": "Grep", "args": {"pattern": "..."}, "ok": true, "latency_ms": 41}
第二条到第四条 Grep 参数几乎一样——从日志里一眼就能看出模型在绕圈,这正是 3.1 节「重复调用检测」想自动拦截的模式,而日志让它先变得可见。
顺带一提,工具本身也在被标准化:MCP(Model Context Protocol) 把「工具」定义成外部可发现、可插拔的服务,模型不需要把函数写死在运行时里,而是通过一个标准协议去连接外部的工具服务器。它解决的问题和 hooks 互补——hooks 管「调用前后做什么」,MCP 管「工具本身怎么定义和接入」。
4.2 显式状态机:慢工具、可失败、有副作用的场景
对于「慢的、可能失败的、有副作用的」工具(例如发通知、创建工单、部署到生产、需要等待人工审批),一个裸 for 循环就不够用了:这些动作中间可能横跨几秒甚至需要人确认,还可能出现「失败重试」「等待中」这些中间态,裸循环只能用 continue 和一堆 flag 硬凑。更好的做法是落成一个显式的有限状态机:
┌──────────────┐
start ─────▶│ THINK │
└──────┬───────┘
│ 产出 tool_call
▼
┌──────────────┐
┌────▶│ ACT │──失败/重试──┐
│ └──────┬───────┘ │
│ │ 成功 │
│ ▼ │
│ ┌──────────────┐ │
└─────│ OBSERVE │ │
└──────┬───────┘ │
│ 追加结果、重新请求模型
▼
┌──────────────┐
│ 有 tool_call│───是──▶ THINK(下一圈)
└──────┬───────┘
│ 否(最终答案)
▼
┌──────────────┐
│ DONE │
└──────────────┘
对应的核心代码骨架:
class StatefulAgent:
def __init__(self, tools, max_iter=15, repeat_threshold=3):
self.tools = tools
self.max_iter = max_iter
self.repeat_threshold = repeat_threshold
def run(self, task: str):
state = State.THINK
messages = [{"role": "user", "content": task}]
history = [] # (tool, args) 去重计数,防绕圈
while state != State.DONE:
if state == State.THINK:
resp = llm.chat(messages, tools=self.tools)
if not resp.tool_calls:
state = State.DONE # 停止条件:模型给出最终结论
return resp.content
self.pending = resp.tool_calls
state = State.ACT
elif state == State.ACT:
for call in self.pending:
if self.is_loop(history, call): # 3.1 防绕圈
messages.append(self.correction(call))
state = State.OBSERVE
break
result = self.safe_execute(call) # 3.3 错误转文本
messages.append(as_assistant_msg(call))
messages.append(as_tool_msg(call, self.format(result)))
else:
state = State.OBSERVE
continue
elif state == State.OBSERVE:
state = State.THINK # 回到 think,进入下一圈
raise MaxIterationError("循环未收敛")
和裸 for 循环相比,状态机的收益是:
- 中间态显式化——ACT 里的「失败重试」「等待人工确认」不再需要用
continue和 flag 硬凑,而是清晰的状态迁移; - 可插桩——每个状态入口都能埋日志/指标,
THINK耗时、ACT成功率、OBSERVE触发频次分开统计,出问题能定位到具体环节; - 可测试——状态迁移逻辑可以脱离真实 LLM 单独测,用 mock 的 tool_call 序列验证「绕圈时是否真的会注入纠偏提示」。
这类场景还有一个特殊约束放大了这些工程问题:工具的副作用不可撤销。get_weather 查十次无所谓,但「发送通知」「创建工单」是有副作用的动作——循环卡死导致的重复调用,意味着用户被重复通知、工单被重复创建。所以 3.1 的重复检测在这里从「性能优化」升级成了「正确性要求」:有副作用的工具必须在 act 前做幂等或去重。这也是为什么 PreToolUse 的「闸门」角色在有副作用的场景里尤其重要——它把「是否允许执行」这个判断,从循环内部挪到了执行之前。
总结
Agent 的本质一句话:LLM + Tool Loop——模型输出「要调用什么工具」,运行时去执行,结果喂回去,如此往复直到模型给出不带工具调用的最终答案。
- 机制上,它由 think → act → observe 三环节构成,就是 ReAct 范式;现代实现把 act 从「解析文本」升级成「解析结构化 tool_calls」,但职责不变。
- 工程上,真正的难点是三个:循环卡死(
(tool, args)去重 +max_iter硬上限)、token 爆炸(每轮 O(M×N) 重发,靠裁剪/压缩/缓存缓解)、结果格式化(observe 的产物是「下一条 prompt」,要裁剪、标状态、控长度)。 - 工程化上,先给循环每一圈埋探针(hooks 的 PreToolUse / PostToolUse 分别是 act 的闸门与记录),再在「慢工具、可失败、有副作用」的场景里把裸循环升级成显式状态机——因为这类场景需要中间态和幂等保护。
理解了这个循环,再看市面上的 LangChain、AutoGPT、Claude Code、Cline,本质上都是在同一个 while not done 骨架外,套上不同的工具注册表、护栏和可观测性。循环是免费的,工程是昂贵的。
章末提问
追问 1:Agent 和「一次 LLM 调用」的本质区别是什么?
结论先行:Agent 不是一次对话,而是一个「LLM 反复调用工具、直到满足停止条件才退出」的循环。
因为:模型一次调用只能输出文本或 tool_call 意图;是运行时判断「有没有 tool_calls」、执行工具、把结果喂回、再请求模型,如此往复。「调用工具」和「最终回答」是同一生成通道的两种产物,不是两种模式。停止条件只有一个——这一轮输出不再带 tool_calls。
追问 2:Tool Loop 怎么保证收敛,而不是烧 token 死循环?
结论先行:靠三层护栏——max_iter 硬上限兜底、(tool, args) 重复调用检测、注入纠偏提示。
因为:死循环的根因是模型误以为「再调一次能拿新信息」,靠模型自律停不下来。所以必须在运行时强制:硬上限保证无论如何能退出,重复检测识别「同参数同工具」的绕圈,纠偏提示不是简单 abort 而是给模型一个「你在绕圈」的信号。真实产品里 Claude Code 用 maxTurns + maxBudgetUsd 这种无差别硬上限兜底,印证了「护栏是工程,不是模型自律」。
追问 3:为什么 Tool Loop 会有 O(M×N) 的 token 爆炸,怎么缓解?
结论先行:因为模型无状态,每一轮都要全量重发历史,N token 跑 M 轮就是 O(M×N);缓解按性价比排是结果裁剪、上下文压缩、prompt caching、子任务隔离。
因为:上下文是自回归的,没有增量记忆,只能靠重读历史维持状态。一个 10KB 的工具结果会在之后每一轮被重发。所以第一优先是在 observe 源头截断(结果裁剪),成本最低收益最高;压缩(摘要)是止损阀但有精度代价;缓存只省不变前缀;子任务隔离是把大循环拆成多个独立小循环。
追问 4:慢的、可失败的、有副作用的工具,为什么不能塞进裸 for 循环?
结论先行:因为这类工具需要中间态(失败重试、等待人工确认)和幂等保护,裸循环只能用 flag 硬凑,正确做法是升级成显式状态机。
因为:裸 for 循环只有「请求—执行—喂回」一条主路径,遇到「等待中」「重试」这些中间态没有表达空间;更关键的是副作用不可撤销——循环卡死导致的重复调用意味着用户被重复通知、工单被重复创建,重复检测从「性能优化」升级成「正确性要求」。状态机把 THINK/ACT/OBSERVE 的迁移显式化,可插桩、可测试,且让 PreToolUse 的「闸门」在 act 之前拦下危险操作。