Skip to content

第 3 章 从一次 API 调用到 while 循环 ​

本章要解决的问题

第 1 章说明了一次模型请求怎样完成,第 2 章说明了多轮请求为什么会重复支付上下文成本,以及缓存怎样减少这部分开销。但一次请求只能生成文本,不会自己读仓库、执行操作,也不会根据操作结果继续决定。

本章回答骨架篇的第一个问题:怎样把一次模型请求变成可以完成多步任务的控制循环。 为了先看懂主干,这里故意只做一个不完善的最小 Agent。它只有一个读文件工具,却已经能完成最关键的循环:

text
模型决定下一步 → 程序执行工具 → 模型看到结果后继续决定

先看懂并跑通这条流程。参数校验、并发、沙箱、长上下文和恢复等问题,从第 4 章开始逐个补。

动手之前,先把贯穿全书的公式立在这里:

text
Agent = LLM + 上下文 + 工具 + Harness

Harness(组织并约束前三部分的工程层)
  = 控制循环 + 上下文组装 + 工具调度 + 状态管理
  + 权限约束 + 沙箱 + 验证

这里的“上下文”指模型看到的信息,“工具”指 Agent 可以调用的能力;Harness 负责组装这些信息、调度这些能力并控制运行过程。把 Harness 单独列出来,是为了强调这层工程责任,而不是把“模型之外的一切”都换一个名字。

约束通常用两种方式实现:

  • 执行时强制: Harness 在运行工具前检查权限,给命令施加操作系统沙箱,并控制并发、取消和重试。条件不满足就拒绝执行。
  • 请求时引导: 上下文写清规则、工具的适用范围和“何时不用”;工具被拒后,程序再把原因返回给模型,让它调整方案。这些文字能影响模型选择,却不能强制阻止操作。

因此,约束可以由执行检查和上下文说明共同完成;沙箱不能由上下文实现,只能由 Harness 和操作系统强制。只写在上下文里的规则不是强制约束,而是建议。

3.1 第一步:只调用一次模型 ​

先实现最简单的问答程序:

text
用户问题 → 调用 LLM API → 显示文字回复 → 程序结束

伪代码只有几行:

text
messages = [{ role: "user", content: 用户问题 }]
reply = callModel(messages)
print(reply.text)

这个程序可以解释一段已经放进问题里的代码,却不能解释本机 src/index.ts,因为模型看不到文件。它很可能回复:“请把文件内容发给我。”

这时有两种做法:

  • 程序提前读取固定文件,把内容塞进请求;
  • 告诉模型有哪些工具,让模型自己决定何时读取哪个文件。

如果任务流程完全固定,第一种更简单。只有当下一步必须根据模型判断或工具结果变化时,才需要 Agent 循环。

3.2 第二步:给模型一个读文件工具 ​

工具先由普通代码实现。它接收路径,返回文件内容:

text
read_file(path) → 文件内容或明确错误

接着,程序在模型请求里加入工具说明:工具名叫 read_file,用途是读取文件,必填参数是字符串 path。

模型看到工具说明后,输出可能不再是最终文字,而是一个调用请求:

text
调用 id: call_1
工具名:  read_file
参数:    { path: "src/index.ts" }

注意,模型没有读取文件。它只是生成了一份“我想调用 read_file”的数据。程序接下来必须:

  1. 根据工具名找到 read_file;
  2. 执行它;
  3. 把结果与 call_1 配对;
  4. 再调用一次模型。

这一次,模型终于能同时看到用户问题和真实文件内容,从而给出有依据的回答。

3.3 第三步:用 while 把过程接起来 ​

真实任务不会永远只调用一次工具。模型读完入口文件,可能还要读取它引用的配置,再搜索一个函数,最后运行测试。程序无法提前写死总共需要几步,所以要循环执行。

从一次模型调用到最小 Agent 循环

流程图已经说明主要控制流。对应的最小伪代码如下:

text
messages = [用户问题]

while 没超过最大步数:
    reply = callModel(messages, tools)
    messages.append(reply)

    if reply 没有工具调用:
        return reply.text

    for call in reply.toolCalls:
        result = executeTool(call.name, call.arguments)
        messages.append(toolResult(call.id, result))

只有四个关键动作:

  1. 保存消息:模型下一轮要看到之前发生了什么。
  2. 调用模型:让模型根据当前消息决定下一步。
  3. 执行工具:模型提出调用后,由程序真正执行。
  4. 判断结束:模型不再调用工具、开始输出最终文字时,本轮结束。

while 必须有最大步数。否则模型反复读同一个文件或一直换搜索词时,程序会永远运行。最小版本可以只限制步数,生产版本还要限制时间、token 和费用。

这就是常说的 ReAct ​

很多资料把这个循环称为 ReAct:Reason、Act、Observe,也就是“决定 → 执行 → 看结果”。它不是某家产品的私有设计——从提出这个词的论文,到各家模型厂商的官方入门文档,教的都是这条循环;产品的差别全在循环之外补了什么。不必先记英文名;你只要记住:

text
模型决定,程序执行,结果回到下一轮输入

模型的完整内部思考不一定会通过 API 返回,也不需要把隐藏思考暴露出来才能实现循环。

3.4 跑一遍:让 Agent 解释一个入口文件 ​

用户输入:

解释 src/index.ts 的作用。如果它引用了本项目文件,也一起查看。

最小 Agent 的运行过程可能是:

步骤模型看到什么模型或程序做什么
1用户问题、工具说明模型调用 read_file(src/index.ts)
2上一步调用、入口文件内容模型发现引用 ./config,调用 read_file(src/config.ts)
3两个文件内容模型认为信息足够,输出最终解释

在这个过程中,模型负责选择文件和决定何时结束;程序负责读盘、保存消息和继续循环。

如果第二次读文件失败,也要把“文件不存在”作为工具结果放回消息。模型看到错误后,才有机会改路径或换搜索方式。直接抛异常让整个进程退出,模型就无法纠正。

3.5 到这里,一个最小 Agent 已经包含什么 ​

先不要背完整架构,只看刚才实际写出的东西:

已有部分在代码中的样子作用
模型callModel(...)根据当前信息决定下一步
上下文messages保存用户问题、模型调用和工具结果
工具read_file让程序接触真实环境
循环while让模型根据新结果继续工作

表中的“循环”,加上准备消息、调用模型和执行工具的代码,就是目前的 Harness。它现在还很小;后面再加入权限、状态和恢复。

3.6 为什么它还不能直接用于生产 ​

为了看清主线,最小版本暂时假设很多事情不会出错:

暂时的假设真实情况
模型一定返回合法参数参数可能是半截 JSON、缺字段或工具名错误
工具很快返回测试可能运行几分钟,用户还会中途取消
文件不会同时变化用户、格式化器和其他任务可能正在修改同一文件
消息可以无限追加文件、日志和历史很快占满上下文
读文件没有风险路径可能越过工作区并读到敏感数据
模型说完成就完成它可能没有运行测试,或只完成了任务的一部分

这正是后续章节存在的原因。不是为了给简单循环套更多概念,而是每个假设都会在真实任务里被打破。

3.7 四个 Agent 的核心也是这条循环 ​

codex、grok-build、dsh 和 pi 的代码规模与产品边界差别很大,但都保留了同一个核心控制流:准备模型输入、接收工具调用、执行、回填、继续。

项目在最小循环之外优先补了什么
pi保持循环和默认工具很小,把很多产品能力交给扩展
dsh用事件记录保存过程,并让循环、工具和模型服务都能替换
codex补齐权限、沙箱、恢复和多端一致性,适合受控执行
grok-build集成更多工具、编辑方式和后台任务,强调复杂能力调度

四者的差别主要不在 while 怎么写,而在循环周围怎样处理失败、权限、状态、并发和恢复。第 4 章就从这些问题开始。

本章小结 ​

  • 一次 LLM 调用只能回答,不能自动读取真实项目。
  • 工具调用是模型生成的动作请求,真正执行发生在普通程序里。
  • while 循环让模型看到工具结果后继续决定,直到给出最终回答。
  • 最小 Agent 已经包含模型、消息、工具和循环。
  • 生产 Agent 的复杂度主要来自循环周围的校验、权限、上下文、状态和恢复。

下一章保留这条最小循环不变,专门看它面对真实模型输出、并发调用、中断和重试时为什么会坏,以及四个 Agent 各自怎样处理。

基于 MIT 协议发布,欢迎转载与贡献。