Agent 的本质:感知、规划、工具调用与反馈闭环

本节目标

为什么调一次 API 不叫 Agent?

许多初学者把封装一段 Prompt 调用 OpenAI 或 Claude 接口称作「写了一个 Agent」。但在软件工程语境下,单次 LLM 调用只是一个无状态函数(Stateless Function):输入一段文本 Token,输出一段预测文本,模型既无法感知外部世界的变化,也无法验证自己给出的代码或结论是否正确。

真正的 AI Agent(智能体) 是一个以大语言模型为控制中枢的闭环状态机(Closed-Loop State Machine)。它的核心特征是:循环控制权(Control Flow)由模型根据环境反馈动态决定,而不是由程序员用硬编码的 if/else 写死。

┌─────────────────────────────────────────────────────────────────┐
│                     Autonomous Agent Loop                       │
│                                                                 │
│  [User Goal] ──► (1. 感知 Perceive: 组装 System + History)      │
│                         │                                       │
│                         ▼                                       │
│                  (2. 思考与决策 Reason: LLM 推理下一步)         │
│                         │                                       │
│            ┌────────────┴────────────┐                          │
│            ▼ (需要调用外部工具)       ▼ (目标已达成 / 终止)      │
│  (3. 行动 Act: 执行本地 JS 函数)   [返回最终答案 Final Answer]   │
│            │                                                    │
│            ▼                                                    │
│  (4. 观察 Observe: 捕获返回值或报错,追加回 Messages 历史) ─────┘
└─────────────────────────────────────────────────────────────────┘

Workflow 与 Agent 的选型边界

Anthropic 在《Building Effective Agents》工程指南中明确将系统分为两类:

维度 编排工作流(Workflows) 自主智能体(Autonomous Agents)
控制流归属 确定性代码预定义路径(DAG、固定管道) 模型根据每一步观察结果动态决定下一跳动作
延迟与成本 轮次固定,Token 消耗与耗时高度可预测 循环轮次不固定,可能因错误重试消耗数倍 Token
适用场景 表单提取、固定流水线审核、多语言翻译同步 开放式代码修复、跨多数据源调研、复杂故障排查
工程防护重点 节点间 Schema 对齐与异常重试 最大步数熔断(maxSteps)、死循环检测与沙箱隔离

手写最小可运行的原生 JS Agent Loop

下面的代码不依赖任何第三方 Agent 框架(如 LangChain),直接用原生 JavaScript 实现一个支持注册工具、多轮循环推理、观察结果回填与最大步数熔断的 Agent 内核:

export class MinimalAgent {
  constructor({ llmClient, systemPrompt, tools = {}, maxSteps = 8 }) {
    this.llm = llmClient
    this.systemPrompt = systemPrompt
    this.tools = tools // { [toolName]: { description, parameters, execute } }
    this.maxSteps = maxSteps
  }

  async run(userGoal) {
    // 1. 初始化会话工作记忆(Working Memory)
    const messages = [
      { role: 'system', content: this.systemPrompt },
      { role: 'user', content: userGoal }
    ]
    const trace = []

    // 2. 进入感知 - 推理 - 行动 - 观察闭环
    for (let step = 1; step <= this.maxSteps; step++) {
      const response = await this.llm.chat({
        messages,
        tools: Object.entries(this.tools).map(([name, t]) => ({
          type: 'function',
          function: { name, description: t.description, parameters: t.parameters }
        }))
      })

      const assistantMsg = response.message
      messages.push(assistantMsg)

      // 3. 终止判定:若模型未请求任何工具调用,说明已得出最终结论
      if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0) {
        return {
          status: 'completed',
          steps: step,
          answer: assistantMsg.content,
          trace
        }
      }

      // 4. 执行工具调用并将环境观察(Observation)写回记忆栈
      for (const call of assistantMsg.tool_calls) {
        const toolName = call.function.name
        const toolDef = this.tools[toolName]
        let observation = ''

        try {
          if (!toolDef) throw new Error(`未注册的工具: ${toolName}`)
          const args = JSON.parse(call.function.arguments || '{}')
          const result = await toolDef.execute(args)
          observation = typeof result === 'string' ? result : JSON.stringify(result)
        } catch (err) {
          // 关键原则:工具报错绝不直接 throw 崩掉主进程,而是作为观察结果喂回模型使其自愈
          observation = JSON.stringify({ error: err.message })
        }

        trace.push({ step, tool: toolName, args: call.function.arguments, observation })
        messages.push({
          role: 'tool',
          tool_call_id: call.id,
          name: toolName,
          content: observation
        })
      }
    }

    // 5. 预算耗尽保护:防止模型陷入无限工具调用死循环
    return {
      status: 'max_steps_exceeded',
      steps: this.maxSteps,
      answer: '任务超出最大推理步数限制,已安全熔断。',
      trace
    }
  }
}

核心工程铁律:把报错当作 Observation 而不是 Crash

注意上方代码中 catch (err) 的处理方式:在传统后端开发中,函数抛错往往直接向上抛出 500 异常;但在 Agent 开发中,模型传错参数或文件不存在是常态。将 { error: err.message } 封装为 role: 'tool' 的消息返还给模型,模型在下一轮 Loop 中看到错误提示后,会自动修正参数并发起第二次调用——这就是 Agent 具备「自愈能力」的底层秘密。

课后练习

练习 1:在上面的 MinimalAgent.run() 循环中,为什么必须设置 maxSteps 上限?如果没有设置,生产环境可能发生什么严重事故?

答案:大模型在面对模糊需求、工具持续返回异常或上下文冲突时,极易陷入「用相同参数反复调用同一工具」的死循环(Infinite Loop)。若无 maxSteps 硬熔断,单个失控请求会在几分钟内烧光数十万 Token 配额并耗尽服务器并发连接。

练习 2:当工具函数 toolDef.execute(args) 抛出异常时,为什么我们把 err.message 塞进 role: 'tool' 的消息里返回给 LLM,而不是直接 throw err 中断程序?

答案:首先,OpenAI/Anthropic 协议要求每一条带有 tool_calls 的 assistant 消息之后,必须紧跟对应 tool_call_id 的 tool 响应消息,否则后续对话状态会损坏;其次,将报错文本作为环境观察(Observation)反馈给模型,能让模型根据错误原因(如路径错误、类型不匹配)在下一轮自主修正参数重试。