Agentpath
动手5 / 31 节 · 预计 45 分钟

八十行写出你自己的 agent loop

不借任何框架,用一个 while 和两个工具跑起来。这八十行是后面所有内容的地基。

学完这节你能做到

  • 从零写出一个能读写文件、能跑命令的最小 agent,并让它完成一个真实小任务
  • 说出循环的三个退出条件,以及每一个漏掉会出什么事
  • 给自己的 loop 加上轮次上限与 token 上限两道保险

这一节不引任何框架,从空文件开始写一个能干活的 agent。写完你会发现两件事: 核心真的只有几十行;而剩下所有的复杂度,都长在「怎么不让它出事」上。

准备一个能跑 Node 的目录和一个 API key。

mkdir mini-agent && cd mini-agent
npm init -y && npm i @anthropic-ai/sdk
export ANTHROPIC_API_KEY=sk-ant-...

骨架

三样东西:一个 messages 数组、一张工具表、一个 while

// agent.js
import Anthropic from '@anthropic-ai/sdk'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'

const client = new Anthropic()

const TOOLS = [
  {
    name: 'read_file',
    description:
      '读取工作目录下的文件。路径为相对路径。超过 400 行会被截断,截断时会明确说明。',
    input_schema: {
      type: 'object',
      properties: { path: { type: 'string' } },
      required: ['path'],
    },
  },
  {
    name: 'bash',
    description:
      '在工作目录里执行 shell 命令,返回 stdout + stderr。命令失败时返回错误文本而不是抛异常。',
    input_schema: {
      type: 'object',
      properties: { cmd: { type: 'string' } },
      required: ['cmd'],
    },
  },
]

/* 工具实现:注意每个分支都返回字符串,绝不抛出去 */
function runTool(name, input) {
  try {
    if (name === 'read_file') {
      const lines = readFileSync(input.path, 'utf8').split('\n')
      const head = lines.slice(0, 400).join('\n')
      return lines.length > 400
        ? `${head}\n\n[已截断,共 ${lines.length} 行。需要后面的内容请用 bash + sed 指定范围。]`
        : head
    }
    if (name === 'bash') {
      return execSync(input.cmd, { encoding: 'utf8', timeout: 60_000 }).slice(0, 30_000)
    }
    return `错误:没有名为 ${name} 的工具`
  } catch (e) {
    // 报错也是有效返回值 —— 这是模型自我纠错的唯一依据
    return `错误:${e.stdout ?? ''}${e.stderr ?? ''}${e.message}`.slice(0, 8000)
  }
}

循环

const MAX_TURNS = 25
const MAX_TOKENS = 400_000

async function run(task) {
  const messages = [{ role: 'user', content: task }]
  let turns = 0
  let used = 0

  while (true) {
    if (++turns > MAX_TURNS) return `已达轮次上限(${MAX_TURNS}),停下来了。`
    if (used > MAX_TOKENS) return `已达 token 上限(${MAX_TOKENS}),停下来了。`

    const resp = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 8000,
      system: '你是一个在本地工作目录里干活的助手。改动前先读相关文件,改完自己验证。',
      tools: TOOLS,
      messages,
    })
    used += resp.usage.input_tokens + resp.usage.output_tokens

    // 模型不再要求调工具 —— 这一轮结束
    if (resp.stop_reason !== 'tool_use') {
      return resp.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n')
    }

    const calls = resp.content.filter((c) => c.type === 'tool_use')
    for (const c of calls) console.log(`  → ${c.name}`, JSON.stringify(c.input).slice(0, 120))

    messages.push({ role: 'assistant', content: resp.content })
    messages.push({
      role: 'user',
      content: calls.map((c) => ({
        type: 'tool_result',
        tool_use_id: c.id,
        content: runTool(c.name, c.input),
      })),
    })
  }
}

console.log(await run(process.argv[2] ?? '看一眼这个目录,告诉我它是干什么的'))

跑起来:

node agent.js "统计这个仓库里各语言的文件数,写进 STATS.md"
先看它怎么犯错

把任务换成一个它一定会踩坑的:比如让它读一个不存在的文件。观察它收到 错误:ENOENT ... 之后的下一步 —— 那一步就是「回灌错误」的全部价值。

三个退出条件,漏一个都出事

退出条件漏掉的后果
stop_reason !== 'tool_use'循环永不结束,模型答完了还在被反复追问
轮次上限卡在一个工具上反复重试,几十轮之后你才发现
token / 预算上限无人值守时能烧掉整个月的额度

再加上两个真实场景必备的:墙钟超时(单个工具卡住不返回)和用户中断 (Ctrl+C 要能干净地停下,不留半截状态)。

!上限要「说清原因」地停

返回一句 已达轮次上限 比静默 break 有用得多 —— 无论是给人看还是喂给上层调用方, 都需要知道它是干完了还是被拦下了。

和 Pi 比一比:多出来的都是什么

你这八十行已经是一个 agent 了。Pi 在同一个位置上多做的事情,大致是这几类:

  • 上下文管理 —— 接近窗口时自动压缩(否则第十几轮直接报错崩掉)
  • 人在环 —— 跑到一半插话(steering)、排队消息、干净地取消
  • 状态 —— 会话存成一棵树,任意节点可以 fork 重开
  • 可改造 —— 工具、命令、快捷键、提示词都能被扩展替换
  • 多模型 —— 十几家提供方,Ctrl+L 随时切
  • 展示 —— 流式事件驱动的 TUI,工具执行过程可见可中断

这份清单就是 L1 剩下几节和 L2 的目录。每一项都是「你自己写下去早晚会遇到」的问题。

检查点单选

你的最小 agent 跑起来后,同一个 bash 命令被连续调用了十几次,参数几乎没变。最该先检查什么?

交作业

把这个 mini-agent 留着,后面几节会反复回来改它:

  1. 加一条 write_file 工具,并给它加上路径白名单(只允许写工作目录内)。
  2. bash 加确认门:命令里出现 rmgit pushcurl 时先问一句。
  3. 打印每轮的累计 token 与估算花费,让成本变成看得见的数。

这三件事分别对应 L2 的权限门、L3 的成本与可观测。你先自己写一版, 读 Pi 的实现时会舒服很多。

延伸资料