Agent 循环的控制流:如何在不确定的环境中做决策
这是《写一个 Coding Agent Harness and TUI 》系列的第 3 篇。 前两篇搭好了 LLM 调用和工具系统——但它们还是两条平行线。 这篇把它们交叉起来。不只是"怎么写循环"——是"确定性控制结构如何管理非确定性行为"。
写一个能调 LLM API 的程序,50 行。写一个能让 LLM 自己决定调哪个工具的程序,加上 90 行核心循环。但 90 行里有三个东西你事先不知道,而你的控制结构必须为它们留好退路。
你不知道 agent 会跑几步。你不知道 agent 会不会走错路。你不知道什么时候 agent 的记忆会溢出。
这三个不确定性不是"代码质量问题"——它们是 ReAct 模式本身固有的。ReAct(Reasoning + Acting)让 agent 比传统程序强大得多,代价是它把你从"编排者"变成了"守卫者"——你不再编排执行路径,你设置护栏。
这篇的六个设计决策都在回答同一个问题:护栏放在哪,多高,以及什么情况下护栏本身会成为障碍。
〇、元问题:控制权从代码转移到 LLM 之后,代码还剩什么角色
传统程序的控制流
输入 → 函数A → 函数B → 函数C → 输出
↑ ↑ ↑
你写的 你写的 你写的
你知道每一步做什么。read_file(path) → parse_config(text) → apply_config(config)。路径是预先编排的,偏离路径 = bug。
Agent 的控制流
输入 → LLM 推理 → 决策A → 执行 → 结果 → LLM 推理 → 决策B → 执行 → ... → 输出
↑ ↑
LLM 决定 LLM 基于上一步结果决定
你不知道 LLM 会选决策 A 还是决策 B。你不知道决策 B 之后还会不会有决策 C。控制权从代码转移到了 LLM。
代码的新角色:护栏,不是编排者
代码不再编排执行路径。代码设置约束:
- 时间约束:最多跑多少轮?(max_turns)
- 行为约束:如果连续做同一件事,还让它继续吗?(重复检测)
- 空间约束:上下文窗口快满了,要不要截断?(token 保护)
这些约束的每一项都是在"让 agent 尽可能自主"和"防止它失控"之间画一条线。 线画得太靠"自主"——agent 可能跑 20 轮消耗大量 token 却没有任何产出。线画得太靠"控制"——agent 任务没完成就被打断,用户永远得不到完整结果。
这篇的六个决策,每个都是这条线的不同侧面。
一、AgentDefinition 与 Agent 分离——静态契约与动态状态的隔离
这条线的一面
AgentDefinition = 静态配置。名字、系统提示词、工具白名单、max_turns。它在 agent 启动前就被定义好了——可以写在文件里、存数据库、版本管理。
Agent = 运行时实例。持有 ToolRegistry 的 Arc、渲染好的提示词、当前会话引用。
为什么必须分开——不是代码整洁
假设 TUI 开了三个 tab,都连同一个 agent 配置。三个 tab 共享 AgentDefinition,但各自拥有独立的 Agent 运行时——各自的对话历史、各自的工具执行上下文。
如果定义和实例不分离,三个 tab 会互相污染对方的对话历史。 一个 tab 里说"我的项目用 Rust",另一个 tab 里就"知道"了这个信息——但它不应该知道。
更深层:第 6 篇的 Leader 模式下,Agent Server 可能同时服务多个 Client。每个 Client 创建自己的 Agent 实例,但共享同一个 ToolRegistry。如果 Runtime 和 Config 混在一起——你无法控制"共享什么、隔离什么"的粒度。
根本原则
静态契约(AgentDefinition)定义 agent 能做什么、不能做什么。运行时实例(Agent)承载 agent 在做什么、做了什么。 契约在编译/配置阶段确定。实例在运行阶段创建和销毁。隔离的边界 = 安全的边界。
二、Builder 模式——为什么"延迟校验"比"便利性"重要十倍
便利性是错觉
Builder 模式看起来像"把构造函数的一堆参数变成链式调用"。如果只做到这个—— Builder 是语法糖,不是架构。
真正的价值:校验的时机 = 安全的时机
let agent = AgentBuilder::new()
.with_name("code-reviewer")
.with_system_prompt("你是一个代码审查专家...")
.with_tools(vec!["read_file", "grep", "nonexistent_tool"]) // 这个工具不存在
.build()?; // ← 校验在这一刻发生,不是在 Agent 创建的时候
with_tools() 时不做校验——工具名可能指向还未注册的工具,Builder 不知道。build() 时做校验——所有信息都收集齐了,Registry 里有什么工具一清二楚。
如果没有 Builder,你的校验逻辑会散落各处。 工具名校验在 Agent::new() 里。提示词渲染在另一个地方。MCP 工具去重在第三个地方。每个地方都可能漏掉一个校验条件——因为信息不全。
Builder 把"收集信息"和"校验信息"分开。收集阶段允许不完整。校验阶段要求全部完整。收集和校验的分离 = 灵活性和安全性的分离。 收集阶段可以自由组合。校验阶段必须全部通过。
三、双轨注入——为什么一种方式通知 LLM"有什么工具"是不够的
提示词 vs API tools 参数
提示词里的工具描述:"你可以使用 read_file 工具来读取文件内容。参数 path 是文件路径。" → LLM 理解"什么时候用"。
API tools 参数里的 JSON Schema:{"name":"read_file","parameters":{"properties":{"path":{"type":"string"}},"required":["path"]}} → LLM 理解"参数怎么填"。
为什么只能用一种不够——不是"两条路更保险"
只用提示词:LLM 从自然语言里理解参数类型。"参数 path 是文件路径"——LLM 可能理解成 path 是必选的,也可能理解成可选的。"参数 timeout 是超时时间,单位是秒"——LLM 可能理解成字符串 "30s"。自然语言有歧义。
只用 JSON Schema:LLM 理解参数的类型和必选标记。但 Schema 不传"使用建议"。LLM 不知道"什么时候该用 read_file 而不是 bash(cat)"。LLM 不知道"用这个工具之前需要先检查什么"。Schema 精确但不传策略信息。
双轨注入的本质:LLM 决策需要两种不同性质的信息
| 提示词(策略层) | API tools 参数(执行层) | |
|---|---|---|
| 信息性质 | 启发式、上下文相关 | 精确、形式化 |
| 回答的问题 | “什么时候用什么” | “参数怎么填” |
| 格式 | 自然语言 | JSON Schema |
| 信息通道宽度 | 宽(自然语言可以描述任何策略) | 窄(只能描述结构) |
| 适合表达 | 工具的使用场景、限制、注意事项 | 参数名称、类型、必选/可选 |
双轨不是"多传一份信息更保险"——是"两种本质上不同的信息需要两种本质上不同的载体"。
四、while 循环 vs 递归——不是性能问题,是"你在第几层"的可见性问题
递归方案
async fn run_turn(messages: &mut Vec<Message>, turn: usize) -> Result<String> {
if turn >= max_turns { return Err("..."); }
let resp = llm.chat(messages).await?;
if resp.has_tool_calls() {
execute_tools(&resp.tool_calls, messages).await?;
return run_turn(messages, turn + 1).await; // 递归
}
Ok(resp.content)
}
循环方案
async fn run_turn(messages: &mut Vec<Message>) -> Result<String> {
for turn in 0..max_turns {
let resp = llm.chat(messages).await?;
if !resp.has_tool_calls() { return Ok(resp.content); }
execute_tools(&resp.tool_calls, messages).await?;
}
Err("超过最大轮次".into())
}
不是性能——是可见性
递归方案里,调用栈深度 = 已执行的轮数。第 5 轮时栈上是 5 层 run_turn。你想检查"当前累积消耗了多少 token"——你必须穿透 5 层调用栈才能拿到外层变量。你想取消执行——你必须从最内层 unwind 5 层。
循环方案里,状态(轮数、token 消耗、上次调用指纹)全是局部变量。任何时候停下来,你都能检查这些值。取消只需要 break。
护栏需要可见性。 如果护栏的状态埋在调用栈里——调试时你根本看不到 agent 在哪一轮、为什么停了、谁触发了 max_turns。循环把状态放在水平空间(局部变量),递归把状态放在垂直空间(调用栈)——水平空间更容易检查。
五、双层保护——什么先触发,为什么
max_turns 和重复检测
max_turns = 10。agent 跑 10 轮还没完成 → 终止。
重复检测:连续两次相同工具 + 相同参数 → 终止。
为什么重复检测必须在前
如果先检查 max_turns:
Turn 1-8: 正常工具调用
Turn 9: LLM 开始重复调用同一个工具(陷入循环)
Turn 10: max_turns 触发 → 浪费了 2 轮 token
max_turns 是"你可能在做正常工作但你做了太多次"。重复检测是"你在做显然没意义的重复"。 后者应该在到达前者之前拦截。
max_turns 的值——不是"越保守越好"
grok-build 默认 10。为什么不是 3?为什么不是 20?
一个典型的"帮我分析这个项目"可能需要 6 轮:
read_file(Cargo.toml) → read_file(src/main.rs) → grep("fn ") → bash("cargo check") → 分析结果 → 回答用户
max_turns = 3 → agent 在第三步就被截断了。用户永远得不到完整回答。
max_turns 不是"防止 agent 跑太久"——是为合理任务留足空间,同时防止无限循环。 10 不是 magic number——它是"够用于 80% 日常任务"和"不至于浪费过多 token"之间的平衡点。这条线应该根据你自己的任务复杂度调整。
六、Token 保护——chars/4 为什么够用
tiktoken 需要加载 1MB+ 词表,精确到个位数 token。chars/4 估算,误差 ±20-30%。
为什么估算够用?因为我们不是在做 token 计费(计费需要精确),我们是在做窗口溢出保护:
if estimated_tokens(messages) > window * 0.8 {
truncate(messages)
}
80% 阈值 + ±30% 估算误差 = 最坏情况是"实际 56% 时触发截断"或"实际 104% 时触发"。
56% → 浪费了一些窗口空间。104% → API 报错,但取决于实现可能只是静默截断尾部。
关键:估算偏保守(高估不低估)或增加安全 margin(70% 而不是 80%),就能消除"超限未触发"的风险。
代价是更频繁的截断。但比起"加载 tiktoken 词表到内存"和"完全不做保护",估算是最实用的中间方案。
这里再次体现了"确定性 vs 概率性"的元问题:你想精确知道 token 数(确定性思维),但代价(词表加载)大于收益(偶尔窗口未充分利用)。在这条线上,实用主义战胜了完美主义。
七、代码
~90 行核心循环 + 5 个支撑文件,tutorial-code/part-03-agent-loop/。
验证:
cargo run
你: 列出当前目录
[🔧] bash(ls) → Cargo.toml src/
助手: 当前目录包含 Cargo.toml 和 src 文件夹。
八、总结:六个决策,一条线
| 决策 | 画的是哪条线 |
|---|---|
| Definition vs Agent 分离 | “共享什么"和"隔离什么"的边界 |
| Builder 延迟校验 | “收集"和"校验"的边界——校验放在信息完整的时机 |
| 双轨注入 | LLM 需要两种不同性质的信息,用两种不同载体 |
| while vs 递归 | 护栏状态放在水平空间(可见)还是垂直空间(不可见) |
| 重复检测先于 max_turns | 语义性异常(无意义重复)先于统计性异常(次数太多) |
| chars/4 估算 | 实用窗口保护的精度需求和成本之间的平衡 |
护栏的工作不是"阻止 agent 做任何事”——是"让 agent 尽可能自主,同时防止不可逆的灾难”。 下一篇这个原则在文件编辑层面具体化:为什么 search_replace 用唯一性校验而不是模糊匹配——因为"给你明确的错误让你修正"比"猜测你的意图并可能改错"更安全。
完整代码在 tutorial-code/part-03-agent-loop/。
🤖 写了自己的 Agent 但跑不稳?
扫码联系获取 Agent 架构咨询服务——帮你看代码里的护栏设计是否合理。

