Agent 的本质:感知、规划、工具调用与反馈闭环
本节目标
- 徹底分清「单次大模型问答(Stateless LLM Call)」、「固定工作流(Workflow)」与「自主智能体(Autonomous Agent)」的工程边界
- 掌握 Agent 四大核心组件(Brain 推理核、Planning 规划器、Memory 记忆栈、Tools 工具箱)在 JavaScript 中的数据流映射
- 用不到 80 行原生 JavaScript 手写一个具备多轮环境交互与终止判定的最小完备 Agent 循环(Agent Loop)
为什么调一次 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)反馈给模型,能让模型根据错误原因(如路径错误、类型不匹配)在下一轮自主修正参数重试。