Skip to content

从零实现一个最小可用的 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 工作流)本质上也是这个判断:在循环之上做工程,而不是重写循环。

参考来源 ​


作者:yanshaodong · 2026-10-18