主题
从零实现一个最小可用的 Agent Loop
实战系列第 1 篇。目标:不用任何 Agent 框架,用 Node.js 写一个 150 行左右、能真正跑完任务的工具调用循环。写完你就明白 LangChain / Claude Code 这类工具在帮你做什么——以及哪些坑框架替你踩了,哪些没有。
一、Agent 的本质结构
剥掉所有框架封装,Agent 只是一个循环:
text
┌─> 1. 把「对话历史 + 工具定义」发给模型
│ 2. 模型要么回复文本(任务完成),要么要求调用工具
│ 3. 要求调用工具 → 执行工具,把结果追加进对话历史 ─┐
└──────────────────────────────────────────────────┘LLM 本身是无状态的:它不能「记得」上一轮、不能「执行」任何东西。Agent 循环做的事情只有两件——维护上下文状态和在模型与工具之间搬运数据。理解这一点后,框架的价值(和局限)就都能对号入座了。
二、完整实现
以下代码可直接运行(Node.js 18+,fetch 内置),模型侧用任意 OpenAI 兼容接口:
javascript
// agent.mjs — 最小可用 Agent Loop
// 运行:OPENAI_BASE_URL=... OPENAI_API_KEY=... OPENAI_MODEL=... node agent.mjs
const BASE_URL = process.env.OPENAI_BASE_URL
const API_KEY = process.env.OPENAI_API_KEY
const MODEL = process.env.OPENAI_MODEL
const MAX_ITERATIONS = 15 // 没有上限的循环 = 失控的账单
const tools = [
{
type: 'function',
function: {
name: 'run_command',
description: '在工作目录执行 shell 命令并返回输出',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: '要执行的命令' }
},
required: ['command']
}
}
}
]
// 工具实现:所有工具都应该有超时,否则一个挂起的命令会卡死整个循环
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
const execAsync = promisify(exec)
async function runCommand(command) {
const { stdout, stderr } = await execAsync(command, {
cwd: process.cwd(),
timeout: 30_000
})
return stdout || stderr || '(no output)'
}
const toolImpl = { run_command: runCommand }
async function chat(messages) {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: MODEL,
messages,
tools
})
})
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`)
return res.json()
}
async function runAgent(task) {
const messages = [
{
role: 'system',
content: '你是一个运维助手,只能通过 run_command 工具了解系统状态。回答要给出命令输出作为证据。'
},
{ role: 'user', content: task }
]
for (let i = 0; i < MAX_ITERATIONS; i++) {
const { choices } = await chat(messages)
const msg = choices[0].message
messages.push(msg)
// 没有 tool_calls 说明模型认为任务完成,循环出口
if (!msg.tool_calls?.length) {
return msg.content
}
// 逐个执行工具调用(多工具调用可能并行出现在一次响应里)
for (const call of msg.tool_calls) {
const args = JSON.parse(call.function.arguments)
console.log(`[iter ${i}] -> ${call.function.name}(${JSON.stringify(args)})`)
let result
try {
result = await toolImpl[call.function.name](args)
} catch (err) {
// 关键:错误要「返回给模型」而不是抛出中断循环,
// 模型看到错误信息才能自我修正
result = `TOOL_ERROR: ${err.message}`
}
messages.push({
role: 'tool',
tool_call_id: call.id,
content: String(result).slice(0, 8000) // 截断超长输出,防止上下文爆炸
})
}
}
return '达到最大迭代次数,任务未确认完成。'
}
const task = process.argv[2] || '统计当前目录下各类型文件的数量,并找出最大的 3 个文件'
console.log(await runAgent(task))bash
# 运行示例
OPENAI_BASE_URL=https://api.example.com/v1 \
OPENAI_API_KEY=*** \
OPENAI_MODEL=gpt-4o-mini \
node agent.mjs "检查 8080 端口被哪个进程占用,如果是 node 进程,输出它的启动命令"三、五个必须处理的坑
以下是这 150 行里看不见但必须存在的防御,全部来自真实翻车:
1. 循环必须有硬上限
没有 MAX_ITERATIONS,模型陷入「调用工具 → 看结果 → 再调用」的循环时,账单和延迟都会失控。15 轮是一个经验起点:正常的工具型任务很少超过 10 轮,超过 15 轮大概率是上下文已经混乱。触发上限时应保留完整 messages 用于事后分析,而不是直接丢弃。
2. 工具报错要喂回给模型,不能抛出
新手最常见的写法是 await tool() 不做捕获——工具一报错整个 Agent 崩溃。正确语义是:错误也是工具结果,TOOL_ERROR: ... 回给模型后,它通常会换一种方式重试。这是 Agent 「自愈能力」的全部来源。
3. 工具输出必须截断
一条 cat 一个 100 万行的日志文件,下一轮请求的 prompt 就报废了。所有工具结果在进 messages 前按字符截断(示例中 8000 字符),截断时最好附加一句 [truncated],让模型知道自己看到的是部分数据。
4. function.arguments 可能不是合法 JSON
部分模型(尤其小参数量的开源模型)会生成带尾逗号或不完整的 JSON。生产代码里 JSON.parse 外要包 try/catch,解析失败时把原始字符串作为错误返回给模型,它通常会修正格式重发。
5. 并行工具调用别乱序
模型一次响应可能返回多个 tool_calls。按顺序执行没问题,但每个结果必须用对应的 tool_call_id 回填——id 配错,后续轮次模型会把 A 工具的结果当成 B 的,且几乎不会报错,只会安静地得出错误结论。这类 bug 排查成本极高,写循环时就要把 tool_call_id 的传递当契约对待。
四、什么时候该上框架
自己写循环的价值在于:循环里的每一行都是你的,出了问题能在 5 分钟内定位。以下信号出现时再考虑框架或现成 Agent 产品:
| 信号 | 说明 |
|---|---|
| 工具数量 > 20 | 需要工具注册、schema 校验、权限分层的基建 |
| 需要 MCP 生态 | 工具协议标准化收益大于自研灵活性 |
| 多 Agent 协作 | 消息路由、共享内存、任务分解需要成熟抽象 |
| 团队多人维护 | 框架的约定强于个人代码的文档 |
反之,如果只是「一个模型 + 少量工具」,150 行自研循环的可调试性远好于引入一整套依赖。本站后续文章的实战(如 Claude Code 工作流)本质上也是这个判断:在循环之上做工程,而不是重写循环。
参考来源
- OpenAI:Function Calling 指南 ——
tool_calls协议的官方定义 - Anthropic:Tool Use —— Claude 系工具调用协议,结构与 OpenAI 基本同构
- Model Context Protocol —— 标准化工具协议,是「自研循环」与「框架生态」之间的中间路线
作者:yanshaodong · 2026-10-18