我是前端开发,最近在补 AI Agent 相关的东西。

刚开始看资料时,我很容易被框架名和概念带跑。tool calling、memory、planning、streaming、eval、multi-agent,每个词都像一个单独的坑。看了一圈之后,我发现自己知道了很多名词,但写不出来一个能跑的东西。

后来我换了个方式:先不碰框架,直接用 TypeScript 手写一个最小 Agent Loop。

目标很小。用户问天气,模型不能直接编答案,它要先决定调用工具;工具返回结果以后,模型再根据真实结果回答用户。

这条链路跑通以后,再看 Agent,就不再只是概念了。

txt
用户输入
-> messages 历史
-> LLM 判断要不要调用工具
-> 本地执行工具
-> 工具结果写回 messages
-> 再请求 LLM
-> 生成最终回答

这篇笔记写什么#

这篇只记录最小 Agent Loop,不讲复杂框架。

我想先把这几件事说明白:

  • messages 是怎么保存上下文的
  • tools 是怎么告诉模型可用工具的
  • toolHandlers 是怎么把模型的工具请求变成本地函数调用的
  • 为什么执行完工具以后,还要再请求一次模型
  • 最小版本为什么也要限制循环轮数

项目代码在我自己的 kk-agent-lab 里。这里的代码示例会省掉一些日志,只保留主流程。

最小例子:查天气#

我先写了一个很简单的天气工具。

它没有调用真实 API,只用模拟数据。因为这一阶段我想验证的是 Agent Loop,不是天气接口。

ts
function get_weather(city: string): string {
  const mockData: Record<string, string> = {
    北京: '晴天,气温 28°C,湿度 40%,东南风 2 级',
    上海: '多云,气温 25°C,湿度 70%,东风 3 级',
    广州: '小雨,气温 32°C,湿度 85%,南风 2 级',
  }
 
  return mockData[city] ?? `暂无 ${city} 的天气数据`
}

用户输入是:

txt
北京天气怎么样?适合出去玩吗?

我希望模型做的事是:

txt
先调用 get_weather({ city: "北京" })
拿到天气结果
再回答适不适合出门

如果模型直接说“北京今天晴天”,那不是 Agent。那只是模型在生成一个看起来合理的回答。

Agent 最少要能做一件事:需要外部信息时,知道去用工具。

项目准备#

这个实验项目用的是 OpenAI-compatible 的 Chat Completions 格式。

依赖大概是这些:

bash
npm install openai dotenv tsx typescript

环境变量:

bash
LLM_API_KEY=your-api-key
LLM_BASE_URL=https://example.com/v1
LLM_MODEL=your-model

初始化客户端:

ts
import 'dotenv/config'
import OpenAI from 'openai'
 
const client = new OpenAI({
  apiKey: process.env.LLM_API_KEY,
  baseURL: process.env.LLM_BASE_URL,
})
 
const model = process.env.LLM_MODEL!

如果用第三方网关,LLM_BASE_URL 一般写到 /v1。不要把 /chat/completions 也写进去,SDK 会自己拼路径。

messages:最原始的短期记忆#

Agent Loop 的第一块是 messages

一开始只有用户消息:

ts
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
  { role: 'user', content: userInput },
]

后面每一轮模型返回什么、工具返回什么,都要继续放进这个数组。

我之前容易把这件事想简单,以为流程是:

txt
模型要调用工具
-> 我执行工具
-> 把结果发给模型

但真实的消息历史更像这样:

txt
user: 北京天气怎么样?适合出去玩吗?
assistant: 我要调用 get_weather({ city: "北京" })
tool: 晴天,气温 28°C,湿度 40%,东南风 2 级
assistant: 北京今天适合出门,注意防晒和补水

中间这条 assistant 消息不能丢。模型需要知道自己刚才发起了哪一次工具调用。

工具结果也不能随便塞一段文本进去。它有专门的 role: 'tool',还要带上 tool_call_id

ts
messages.push({
  role: 'tool',
  tool_call_id: toolCall.id,
  content: result,
})

tool_call_id 是用来对应工具调用和工具结果的。尤其是一轮里调用多个工具时,这个字段就更重要。

tools:给模型看的工具说明#

第二块是 tools

这部分不是工具实现。它只是告诉模型:你现在有哪些工具,每个工具需要什么参数。

ts
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      description: '查询指定城市的实时天气',
      parameters: {
        type: 'object',
        properties: {
          city: {
            type: 'string',
            description: '城市名,例如:北京、上海',
          },
        },
        required: ['city'],
      },
    },
  },
]

这段配置有点像给模型看的 API 文档。

模型会根据用户问题和工具描述,决定要不要调用工具。如果它决定调用工具,会返回工具名和参数。

这里要注意一个细节:工具描述不要写得太虚。比如“处理用户问题”这种描述没有意义。工具应该说明自己能做什么、需要什么参数、返回什么结果。

toolHandlers:真正执行工具的地方#

第三块是本地工具注册表。

模型只会返回:

txt
我要调用 get_weather,参数是 { city: "北京" }

它不会替我们执行函数。真正执行函数的是本地代码。

ts
type Args = Record<string, string>
 
const toolHandlers: Record<string, (args: Args) => string> = {
  get_weather: args => get_weather(args['city'] ?? ''),
}

这层很像前端里的事件处理。

按钮被点击以后,我们根据事件找到对应 handler;模型请求工具以后,我们根据工具名找到对应 handler。

txt
tool name
-> toolHandlers[name]
-> handler(args)
-> result

如果模型返回了一个不存在的工具名,也不能让程序直接崩掉。可以返回一条错误消息,让模型在下一轮知道工具调用失败了。

ts
const handler = toolHandlers[name]
const result = handler ? handler(args) : `工具 "${name}" 未找到`

真实项目里还要加参数校验。模型返回的参数不一定永远符合 schema。

主循环:让模型和工具来回配合#

前面三块只是准备。Agent Loop 的核心在循环里。

ts
async function runAgentLoop(userInput: string) {
  const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
    { role: 'user', content: userInput },
  ]
 
  for (let round = 1; round <= 5; round++) {
    const res = await client.chat.completions.create({
      model,
      messages,
      tools,
      tool_choice: 'auto',
    })
 
    const choice = res.choices[0]
    if (!choice) break
 
    const { message, finish_reason } = choice
    messages.push(message)
 
    if (finish_reason === 'stop') {
      console.log(message.content)
      return
    }
 
    if (finish_reason === 'tool_calls') {
      const toolCalls = message.tool_calls ?? []
 
      for (const toolCall of toolCalls) {
        if (toolCall.type !== 'function') continue
 
        const name = toolCall.function.name
        const args = JSON.parse(toolCall.function.arguments) as Args
 
        const handler = toolHandlers[name]
        const result = handler ? handler(args) : `工具 "${name}" 未找到`
 
        messages.push({
          role: 'tool',
          tool_call_id: toolCall.id,
          content: result,
        })
      }
 
      continue
    }
  }
}

这段代码里有两个判断最重要。

第一个是:

ts
finish_reason === 'stop'

这表示模型已经完成回答,不需要再调用工具。

第二个是:

ts
finish_reason === 'tool_calls'

这表示模型现在不想直接回答。它需要我们先执行工具。

执行完工具后,循环会继续。下一轮请求模型时,messages 里已经有了工具结果,所以模型可以基于工具结果生成最终回答。

为什么要限制轮数#

我在循环里写了:

ts
for (let round = 1; round <= 5; round++)

这不是随手写的。

Agent 会调用工具,也会根据工具结果继续做判断。如果提示词写得不好,或者工具返回内容让模型误解,它可能一直调用工具。

最小 demo 里也应该加轮数限制。

真实一点的 Agent 还要记录每一轮做了什么,比如:

txt
第 1 轮:模型请求 get_weather
第 2 轮:模型生成最终回答

不记录 trace,调试时会很痛苦。你只能看到最后失败了,但不知道它哪一步开始跑偏。

一次完整运行#

输入:

txt
北京天气怎么样?适合出去玩吗?

第一轮模型返回工具调用:

txt
get_weather({ "city": "北京" })

本地执行工具:

txt
晴天,气温 28°C,湿度 40%,东南风 2 级

把工具结果放回 messages 后,第二轮模型生成回答:

txt
北京今天是晴天,气温 28°C,湿度 40%,东南风 2 级。整体比较适合出门,可以注意防晒和补水。

到这里,最小 Agent Loop 就跑通了。

代码很短,但结构已经完整:

txt
用户问题
-> 模型决定调用工具
-> 程序执行工具
-> 工具结果进入上下文
-> 模型根据结果回答

我踩到的几个坑#

1. tool name 要完全一致#

tools 里写的是:

ts
name: 'get_weather'

toolHandlers 里也必须是:

ts
get_weather: args => get_weather(args['city'] ?? '')

名字不一致时,模型会以为工具存在,但本地找不到执行函数。

2. arguments 是字符串#

模型返回的参数在这里:

ts
toolCall.function.arguments

它通常是 JSON 字符串,不是对象。

所以要先解析:

ts
const args = JSON.parse(toolCall.function.arguments)

这段在正式代码里要包 try/catch,不能默认模型永远返回合法 JSON。

3. assistant 的 tool_calls 消息要保留#

这句很容易被忽略:

ts
messages.push(message)

不管模型这轮是最终回答,还是请求工具调用,都要先把这条 assistant message 放进历史。

后面再追加工具结果:

ts
messages.push({
  role: 'tool',
  tool_call_id: toolCall.id,
  content: result,
})

顺序不能乱。

4. 工具结果要控制长度#

天气工具返回很短,看不出问题。

如果工具是 read_fileexec_shell,结果可能非常长。直接塞回 messages,上下文很快就爆了。

后面我写 coding agent 时,就给读文件工具加了截断。比如文件超过一定字符数,只返回前面一部分,并提示内容被截断。

从天气工具到 Coding Agent#

天气工具跑通以后,我把工具换成了更接近开发场景的四个:

txt
read_file(path)
write_file(path, content)
list_dir(path)
exec_shell(cmd)

这就接近一个最小 coding agent 了。

它可以先列目录,再读文件;需要统计时,可以执行命令;最后把结果整理成自然语言。

比如任务是:

txt
扫描某个目录下的 .tsx 文件,统计行数最多的 3 个文件。

模型可能会走这个流程:

txt
list_dir
-> exec_shell("wc -l ...")
-> 根据命令结果回答

这里我第一次感觉到,Agent 不只是“会聊天的模型”。它开始能操作环境了。

但这也带来另一个问题:工具越真实,边界越重要。

exec_shell 不能随便执行所有命令。
write_file 不能随便写系统目录。
read_file 不能把超大文件完整塞回上下文。

这些限制不影响理解 Agent Loop,但会影响一个 Agent 能不能安全稳定地跑。

我现在怎么理解 Agent#

写完这个最小版本后,我现在会先把 Agent 理解成这几块:

txt
LLM
tools
messages
loop
边界控制

LLM 负责判断下一步。
tools 提供外部能力。
messages 保存过程。
loop 把模型和工具接起来。
边界控制决定它能做什么、不能做什么。

很多框架里的概念,其实都能在这个最小版本里找到起点。

memory 最开始就是 messages
tool use 最开始就是工具定义和本地 handler。
planning 可以先理解成模型在每一轮里决定下一步。
eval 是为了确认修改以后,Agent 还能稳定完成固定任务。

先把这些写出来,再看框架,脑子里会有一个落点。

后面继续做什么#

我接下来不打算马上上很大的框架。

先继续补这几块:

txt
streaming
context compact
provider adapter
eval
更真实的工具设计

流式输出解决等待体验。
上下文压缩解决长任务里 messages 越来越大的问题。
Provider adapter 用来隔离不同模型接口的差异。
Eval 用来检查这个 agent 有没有被自己改坏。

这些东西看起来没有那么炫,但对我这种从前端往 AI Agent 方向转的人来说,比较踏实。

我现在的学习策略是:先把一条最小链路写清楚,再往上加能力。每加一层,都知道它解决的具体问题。