← 全部文章

Agent 的最小闭环:输入、思考、行动、观察

不从业务流程背概念,而是从一次真实的多步排障出发,看懂模型、Runtime 与工具如何共同形成 Agent Loop。

Agent 的最小闭环:输入、思考、行动、观察
文章目录
  1. 导语
  2. 一、先看它实际跑一遍
  3. 二、一轮循环里,三方分别做了什么
  4. 模型负责做语义判断
  5. Runtime 负责驱动和约束执行
  6. 工具负责接触外部环境
  7. 三、Input、Decision、Action、Observation 到底是什么
  8. Input:这一轮模型能看到的信息
  9. Decision:模型对外给出的下一步
  10. Action:通过检查后真正发生的动作
  11. Observation:动作带回来的结果
  12. 四、通用 Agent 不应被一个场景绑住
  13. 五、最小 Agent Loop 的代码骨架
  14. executeChecked 为什么不能省
  15. 六、循环停止了,不等于任务完成了
  16. 七、Agent 为什么容易原地打转
  17. 八、ReAct 与今天的 Tool Calling 是什么关系
  18. 九、这个最小循环会怎样长成完整 Agent
  19. 十、用一个新任务检验是否真的理解
  20. 十一、这一篇真正要记住的事
  21. 下一篇预告
  22. 参考资料

导语

上一篇,我们用一个问题区分了 Chatbot、Workflow 和 Agent:任务执行到一半时,谁根据最新结果决定下一步?

如果答案是模型会根据中间结果继续选择动作,那么接下来的问题就是:这个过程在代码里怎么发生?

假设用户交给开发助手这样一个任务:

帮我查清登录按钮点击后没有反应的原因,先不要修改代码。

它可能先读取登录页组件,发现点击事件确实触发了;然后查看浏览器请求,发现接口返回 401;再读取服务端日志,最终定位到测试环境的签名密钥配置错误。

第二步之所以发生,是因为第一步排除了“按钮没有绑定事件”;第三步之所以发生,是因为第二步取得了 401 这个新线索。

Agent 就这样一边行动,一边获得新信息,再用新信息决定下一步。这个不断重复的过程,就是 Agent Loop。

这一篇只做一件事:建立整套课程后续章节都会复用的运行心智模型。读完后,你应该能看懂一次 Agent 运行中,模型、Runtime 和工具究竟怎样接力。


一、先看它实际跑一遍

把刚才的排障过程写成运行记录,大致是这样:

用户
查清登录按钮点击后没有反应的原因,先不要修改代码。

第 1 轮
模型 → read_file({ path: "src/pages/Login.tsx" })
工具 → 点击事件已绑定,提交时会调用 login()

第 2 轮
模型 → inspect_network({ urlPattern: "/api/login" })
工具 → POST /api/login 返回 401,错误码为 INVALID_SIGNATURE

第 3 轮
模型 → search_logs({ query: "INVALID_SIGNATURE" })
工具 → 测试环境 JWT_SECRET 与签发服务不一致

第 4 轮
模型 → 返回排查结论、证据和建议,不修改代码

如果这是固定 Workflow,开发者需要提前写死“先读组件,再看网络,再查日志”。但真实排障并不总走这条路径:第一步也可能发现按钮被遮挡,接下来应该检查页面样式;接口也可能返回 500,下一步要看的就是错误堆栈。

Agent 的价值,正是让后续动作随着观察结果变化。

不过模型并没有直接读取文件、打开浏览器或查询日志。它只是提出了结构化的工具调用,真正的动作由承载 Agent 的程序执行。这个程序通常被称为 Agent Runtime,也就是驱动模型和工具反复交互的运行时。


二、一轮循环里,三方分别做了什么

下面这张图只表达最小执行关系。先抓住主干,暂时不用考虑重试、并行工具和断点恢复。

流程图

在这个最小模型里,三方各有自己的主要职责:

模型负责做语义判断

模型理解用户目标和已有结果,然后选择:

  • 直接回答;
  • 询问用户;
  • 或提出一个 Tool Call。

例如它看到 POST /api/login 返回 401,判断接下来值得查询相关错误日志。这是语义决策,适合由模型完成。

Runtime 负责驱动和约束执行

Runtime 保存当前消息,调用模型,接收 Tool Call,检查工具与参数,再把允许的动作交给工具。

它还应负责最大轮数、超时、取消、权限拦截等确定性规则。模型可以建议调用工具,但不能绕过这些规则。

工具负责接触外部环境

工具才是真正读取文件、请求 API、查询日志或操作浏览器的代码。执行结束后,它返回成功结果或错误信息。

可以把它们的关系记成一句话:

模型选择下一步,Runtime 管住执行过程,工具读取或改变环境。

这张分工图是整篇文章的核心。后面的 Tool Calling、权限、状态和可观测性,都是在这三个位置上继续增加工程能力。

模型提出动作、Runtime 约束执行、工具接触环境的职责分工


三、Input、Decision、Action、Observation 到底是什么

现在再回头看标题里的四个阶段,它们就不再抽象了。

Input:这一轮模型能看到的信息

Input 不只是用户刚输入的一句话。它通常包含:

  • 用户当前的任务;
  • 需要保留的历史消息;
  • 前几轮取得的工具结果;
  • 本轮允许使用的工具说明;
  • 应用提供的项目、账号或页面等运行上下文;
  • Agent 的稳定行为原则。

Runtime 负责组装这些信息,再提交给模型。至于哪些历史信息应该保留、怎样压缩,是后面 Context Engineering 和状态管理要解决的问题。

Decision:模型对外给出的下一步

很多资料沿用 ReAct 中的 Thought,把这一阶段叫“思考”。这个词可以帮助入门,但工程实现真正需要处理的,是模型对外给出的结果:回答、提问或 Tool Call。

因此本文用 Decision 表示可被程序消费的决定,不要求模型暴露完整内部推理。

一个 Tool Call 可能长这样:

{
  "id": "call_02",
  "name": "inspect_network",
  "arguments": {
    "urlPattern": "/api/login"
  }
}

它表达的是“模型请求调用这个工具”,不代表动作已经成功。

Action:通过检查后真正发生的动作

Runtime 找到 inspect_network 工具,校验参数和权限,通过后才执行它。

同一个 Tool Call 可能有三种结果:

  • 工具获准执行并成功;
  • 参数不合法,被 Runtime 拒绝;
  • 动作需要用户确认,运行暂停。

因此,模型决定调用工具和系统真正执行工具,是两个不同事件。

Observation:动作带回来的结果

工具执行后返回 Tool Result:

{
  "toolCallId": "call_02",
  "isError": false,
  "output": {
    "method": "POST",
    "path": "/api/login",
    "status": 401,
    "errorCode": "INVALID_SIGNATURE"
  }
}

这就是 Observation。Runtime 把它追加到消息中,下一次调用模型时一并提交。

toolCallId 很重要。它让系统和模型知道,这份结果对应哪一次调用。如果一轮里有多个工具调用,这种对应关系更不能丢。

Observation 也可能是错误:超时、权限不足、参数错误和空结果都应该被明确表示。模型只有看到真实结果,才能判断是重试、换方法、询问用户还是停止。


四、通用 Agent 不应被一个场景绑住

理解 Input 后,就能回答一个常见的设计问题:system prompt 到底应该放什么?

对于通用开发 Agent,下面这些属于相对稳定的行为原则:

- 根据用户目标和工具结果选择下一步。
- 需要外部事实时使用工具验证,不要猜测。
- 没有验证的工作,不要声称已经完成。
- 缺少关键信息或授权时,明确告诉用户。

它们对前端排障、后端排障、代码解释和文档维护都适用。

但本次任务要求应该随任务进入:

目标:定位登录失败的原因。
边界:只读排查,不修改代码。
交付:给出已验证的原因、对应证据和修复建议。

换成另一个任务,这部分自然会变化。

还有一些限制不能只写在 prompt 里:

只允许访问当前工作区
最多运行 10 轮
写文件前必须获得确认

Runtime 应该用路径检查、计数器和权限状态真正执行这些限制。

于是,输入相关的信息可以分成三层:

信息 生命周期 典型承载位置
通用行为原则 跨多类任务保持稳定 system prompt
当前目标与交付要求 随每次任务变化 用户消息或任务上下文
可确定执行的硬限制 由系统强制保证 Runtime、权限系统或 Sandbox

这不是某个 API 强制规定的唯一架构,而是一条避免过拟合的工程原则:不要把短期任务写成长期身份,也不要把安全边界只写成自然语言愿望。

专用 Agent 当然可以拥有领域提示和固定工具集。例如客服 Agent 可以长期了解工单规则。但即便如此,账号权限、写操作确认和资源范围仍应由系统强制执行。


五、最小 Agent Loop 的代码骨架

下面用一段与模型服务商无关的 TypeScript,把前面的接力写成代码。

type ToolCall = {
  id: string
  name: string
  arguments: unknown
}

type AssistantMessage = {
  role: 'assistant'
  text: string
  toolCalls: ToolCall[]
}

type ToolResultMessage = {
  role: 'tool'
  toolCallId: string
  name: string
  output: unknown
  isError: boolean
}

type AgentMessage =
  | { role: 'user'; content: string }
  | AssistantMessage
  | ToolResultMessage

type Tool = {
  name: string
  schema: unknown
  execute: (args: unknown) => Promise<unknown>
}

async function runAgent(
  userTask: string,
  tools: Tool[]
) {
  const messages: AgentMessage[] = [
    { role: 'user', content: userTask }
  ]

  for (let turn = 0; turn < 10; turn++) {
    const assistant = await callModel({
      systemPrompt: GENERAL_AGENT_PROMPT,
      messages,
      tools: tools.map(toToolDefinition)
    })

    messages.push(assistant)

    if (assistant.toolCalls.length === 0) {
      return {
        status: 'stopped',
        text: assistant.text,
        messages
      }
    }

    for (const toolCall of assistant.toolCalls) {
      const toolResult = await executeChecked(
        toolCall,
        tools
      )

      messages.push(toolResult)
    }
  }

  return {
    status: 'limit_reached',
    messages
  }
}

先顺着正常路径读:

  1. Runtime 把用户任务放进消息列表;
  2. Runtime 把消息和工具定义交给模型;
  3. 模型返回一条 assistant 消息;
  4. 如果消息中没有 Tool Call,本轮运行停止;
  5. 如果有 Tool Call,Runtime 校验并执行;
  6. Tool Result 被放回消息列表;
  7. 下一轮模型看到新结果后继续决定。

这一骨架与 pi-agent 的核心循环一致:模型产生 assistant 消息;其中有工具调用就执行工具并追加结果;没有工具调用时,循环结束。pi-agent 还处理了流式事件、并行或串行工具、用户中途追加消息、参数校验和中止等生产能力。

OpenAI 的 Function Calling 文档描述的也是同一类往返:把工具提供给模型,接收函数调用,在应用侧执行,再把带有关联 ID 的函数结果交回模型。不同 SDK 的字段名会变化,循环本身没有变。

executeChecked 为什么不能省

模型输出的参数不是可信输入。执行前至少要确认工具存在,并按 schema 校验参数:

async function executeChecked(
  call: ToolCall,
  tools: Tool[]
): Promise<ToolResultMessage> {
  const tool = tools.find(item => item.name === call.name)

  if (!tool) {
    return {
      role: 'tool',
      toolCallId: call.id,
      name: call.name,
      output: { code: 'TOOL_NOT_ALLOWED' },
      isError: true
    }
  }

  try {
    const args = validate(tool.schema, call.arguments)
    await enforcePolicy(call.name, args)
    const output = await tool.execute(args)

    return {
      role: 'tool',
      toolCallId: call.id,
      name: call.name,
      output,
      isError: false
    }
  } catch (error) {
    return {
      role: 'tool',
      toolCallId: call.id,
      name: call.name,
      output: normalizeError(error),
      isError: true
    }
  }
}

这里先展示职责,不展开 schema 怎么写、错误结果怎样设计。它们正是第三、四、五篇要继续回答的问题。


六、循环停止了,不等于任务完成了

代码里,模型没有返回 Tool Call 时,循环就停止。但产品不能因此直接显示“任务已完成”。

模型的文字可能是:

我需要知道问题出现在哪个环境。

这时状态应该是 waiting_for_user。

也可能是:

目前只能确认接口返回 401,尚未取得服务端日志。

这更接近 incomplete。

只有当当前任务要求已经满足,产品才应标记 completed。因此可以把“循环为何停下”和“任务是否成功”分开:

运行状态 含义
completed 已交付当前任务要求的结果
waiting_for_user 缺少信息、授权或确认
incomplete 已有部分结果,但无法继续完成
limit_reached 达到 Runtime 的时间、轮数或成本限制

谁来判断 completed 没有唯一答案。简单任务可以由模型输出结构化状态,再由程序校验必填结果;关键任务还可以增加专门验证步骤。这里要记住的是:“不再调用工具”只是一条停止信号,不是一张完成证明。


七、Agent 为什么容易原地打转

Agent Loop 给了模型多次尝试的机会,也带来了循环风险。

最典型的情况是:模型重复调用同一个搜索工具,工具每次都返回相同空结果,模型却没有得到足够信息改变策略。

只写一句“不要重复调用”不够。Runtime 至少要提供确定性的保护:

  • 最大轮数和最长运行时间;
  • 工具调用次数或成本预算;
  • 相同工具与参数的重复检测;
  • 用户取消信号。

工具结果也要明确区分“没有找到数据”和“查询失败”。如果所有异常都只返回 failed,模型很难知道下一步应该换关键词、稍后重试,还是请求权限。

任务本身则需要可判断的交付要求。“尽可能深入排查”没有自然终点;“输出已验证原因、证据和待验证项,不修改代码”更容易收口。

任务要求帮助模型判断何时可以交付,Runtime 限制负责在异常路径上强制止损。两者缺一不可。


八、ReAct 与今天的 Tool Calling 是什么关系

ReAct 是 Reasoning and Acting 的缩写。论文讨论的关键思想,是让推理与面向环境的行动交错发生:一次行动取得的新信息,会改变后续判断。

这正是 Agent Loop 的思想来源之一。

但生产系统不必要求模型公开输出下面这种自由文本:

Thought: 我需要查询登录请求
Action: inspect_network
Observation: 请求返回 401

现代工具调用通常把 Action 表示为结构化 Tool Call,把 Observation 表示为 Tool Result。这样更容易校验参数、关联调用、控制权限和记录运行轨迹。

所以,ReAct 帮助我们理解“判断与行动为什么要交替”;Function Calling 和 Agent Runtime 则给出了更适合工程实现的消息与执行方式。


九、这个最小循环会怎样长成完整 Agent

现在回看整套课程,后面的主题都可以挂到这个循环上:

后续主题 它在解决循环里的什么问题
Tool Calling 模型怎样表达一次行动请求
Tool Schema 怎样让模型选对工具、传对参数
Tool Result 怎样把真实结果清楚地送回下一轮
状态管理 长任务中哪些消息、步骤和状态需要保存
Memory 与 Context 哪些历史信息应该在这一轮被模型看到
Planning 什么时候先规划,什么时候根据结果即时行动
权限与 Human-in-the-loop 哪些动作能自动执行,哪些必须停下来确认
可观测性与 Eval 怎样复盘循环为什么成功或跑偏
Sandbox 与安全 工具真正执行时,怎样限制影响范围

前端工程师也能从这条循环找到自己的位置:模型和工具产生的事件,需要被转成“正在读取文件”“等待确认”“已完成”等可理解状态;取消、重试和接管也需要明确交互。

后端工程师面对的则是一个带模型决策的任务执行器:消息要持久化,工具要受控,错误要分类,运行要能停止和恢复。

这一篇不提前解决所有问题。它的作用,是给后面每一个工程模块找到准确位置。


十、用一个新任务检验是否真的理解

不要回看前文,试着用自己的话解释下面的过程:

用户:找出首页首屏变慢的原因,先不要改代码。

然后回答四个问题:

  1. 模型想读取构建产物时,谁真正执行动作?
  2. bundle 分析结果怎样影响下一步?
  3. “先不要改代码”应该只靠 system prompt 保证吗?
  4. 模型停止调用工具时,任务一定完成了吗?

如果你能回答出下面的意思,就已经抓住了最小闭环:

  • 模型提出 Tool Call,Runtime 校验后由工具执行;
  • Tool Result 回到消息,成为下一轮 Input 的一部分;
  • 只读限制应随任务传入,Runtime 还要真正拦截写工具;
  • 停止调用只是循环结束,产品仍要判断是完成、等待用户还是未完成。

这也是一次费曼式检验:不仅记得四个名词,还能用一个全新的例子重新搭出整个过程。


十一、这一篇真正要记住的事

上一篇说明了 Agent 为什么不同于一次问答和固定工作流。这一篇把差异落到了运行时:

Runtime 把当前消息和工具交给模型;模型回复或提出 Tool Call;Runtime 校验后执行工具,并把 Tool Result 放回消息;模型根据新结果继续,直到交付、等待用户或受控停止。

模型、Runtime 和工具的边界必须清楚:

  • 模型负责语义判断和动作选择;
  • Runtime 负责驱动循环、执行硬限制;
  • 工具负责接触真实环境并返回结果。

下一篇,我们会把其中一次 Tool Call 单独放大:模型看到的工具是什么,调用请求如何生成,应用又如何安全地接住它。


下一篇预告

下一篇:Tool Calling:Agent 真正开始干活的地方。


参考资料