我是前端开发,最近在补 AI Agent 相关的东西。
刚开始看资料时,我很容易被框架名和概念带跑。tool calling、memory、planning、streaming、eval、multi-agent,每个词都像一个单独的坑。看了一圈之后,我发现自己知道了很多名词,但写不出来一个能跑的东西。
后来我换了个方式:先不碰框架,直接用 TypeScript 手写一个最小 Agent Loop。
目标很小。用户问天气,模型不能直接编答案,它要先决定调用工具;工具返回结果以后,模型再根据真实结果回答用户。
这条链路跑通以后,再看 Agent,就不再只是概念了。
用户输入
-> messages 历史
-> LLM 判断要不要调用工具
-> 本地执行工具
-> 工具结果写回 messages
-> 再请求 LLM
-> 生成最终回答这篇笔记写什么#
这篇只记录最小 Agent Loop,不讲复杂框架。
我想先把这几件事说明白:
messages是怎么保存上下文的tools是怎么告诉模型可用工具的toolHandlers是怎么把模型的工具请求变成本地函数调用的- 为什么执行完工具以后,还要再请求一次模型
- 最小版本为什么也要限制循环轮数
项目代码在我自己的 kk-agent-lab 里。这里的代码示例会省掉一些日志,只保留主流程。
最小例子:查天气#
我先写了一个很简单的天气工具。
它没有调用真实 API,只用模拟数据。因为这一阶段我想验证的是 Agent Loop,不是天气接口。
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} 的天气数据`
}用户输入是:
北京天气怎么样?适合出去玩吗?我希望模型做的事是:
先调用 get_weather({ city: "北京" })
拿到天气结果
再回答适不适合出门如果模型直接说“北京今天晴天”,那不是 Agent。那只是模型在生成一个看起来合理的回答。
Agent 最少要能做一件事:需要外部信息时,知道去用工具。
项目准备#
这个实验项目用的是 OpenAI-compatible 的 Chat Completions 格式。
依赖大概是这些:
npm install openai dotenv tsx typescript环境变量:
LLM_API_KEY=your-api-key
LLM_BASE_URL=https://example.com/v1
LLM_MODEL=your-model初始化客户端:
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。
一开始只有用户消息:
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: 'user', content: userInput },
]后面每一轮模型返回什么、工具返回什么,都要继续放进这个数组。
我之前容易把这件事想简单,以为流程是:
模型要调用工具
-> 我执行工具
-> 把结果发给模型但真实的消息历史更像这样:
user: 北京天气怎么样?适合出去玩吗?
assistant: 我要调用 get_weather({ city: "北京" })
tool: 晴天,气温 28°C,湿度 40%,东南风 2 级
assistant: 北京今天适合出门,注意防晒和补水中间这条 assistant 消息不能丢。模型需要知道自己刚才发起了哪一次工具调用。
工具结果也不能随便塞一段文本进去。它有专门的 role: 'tool',还要带上 tool_call_id。
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
})tool_call_id 是用来对应工具调用和工具结果的。尤其是一轮里调用多个工具时,这个字段就更重要。
tools:给模型看的工具说明#
第二块是 tools。
这部分不是工具实现。它只是告诉模型:你现在有哪些工具,每个工具需要什么参数。
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:真正执行工具的地方#
第三块是本地工具注册表。
模型只会返回:
我要调用 get_weather,参数是 { city: "北京" }它不会替我们执行函数。真正执行函数的是本地代码。
type Args = Record<string, string>
const toolHandlers: Record<string, (args: Args) => string> = {
get_weather: args => get_weather(args['city'] ?? ''),
}这层很像前端里的事件处理。
按钮被点击以后,我们根据事件找到对应 handler;模型请求工具以后,我们根据工具名找到对应 handler。
tool name
-> toolHandlers[name]
-> handler(args)
-> result如果模型返回了一个不存在的工具名,也不能让程序直接崩掉。可以返回一条错误消息,让模型在下一轮知道工具调用失败了。
const handler = toolHandlers[name]
const result = handler ? handler(args) : `工具 "${name}" 未找到`真实项目里还要加参数校验。模型返回的参数不一定永远符合 schema。
主循环:让模型和工具来回配合#
前面三块只是准备。Agent Loop 的核心在循环里。
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
}
}
}这段代码里有两个判断最重要。
第一个是:
finish_reason === 'stop'这表示模型已经完成回答,不需要再调用工具。
第二个是:
finish_reason === 'tool_calls'这表示模型现在不想直接回答。它需要我们先执行工具。
执行完工具后,循环会继续。下一轮请求模型时,messages 里已经有了工具结果,所以模型可以基于工具结果生成最终回答。
为什么要限制轮数#
我在循环里写了:
for (let round = 1; round <= 5; round++)这不是随手写的。
Agent 会调用工具,也会根据工具结果继续做判断。如果提示词写得不好,或者工具返回内容让模型误解,它可能一直调用工具。
最小 demo 里也应该加轮数限制。
真实一点的 Agent 还要记录每一轮做了什么,比如:
第 1 轮:模型请求 get_weather
第 2 轮:模型生成最终回答不记录 trace,调试时会很痛苦。你只能看到最后失败了,但不知道它哪一步开始跑偏。
一次完整运行#
输入:
北京天气怎么样?适合出去玩吗?第一轮模型返回工具调用:
get_weather({ "city": "北京" })本地执行工具:
晴天,气温 28°C,湿度 40%,东南风 2 级把工具结果放回 messages 后,第二轮模型生成回答:
北京今天是晴天,气温 28°C,湿度 40%,东南风 2 级。整体比较适合出门,可以注意防晒和补水。到这里,最小 Agent Loop 就跑通了。
代码很短,但结构已经完整:
用户问题
-> 模型决定调用工具
-> 程序执行工具
-> 工具结果进入上下文
-> 模型根据结果回答我踩到的几个坑#
1. tool name 要完全一致#
tools 里写的是:
name: 'get_weather'toolHandlers 里也必须是:
get_weather: args => get_weather(args['city'] ?? '')名字不一致时,模型会以为工具存在,但本地找不到执行函数。
2. arguments 是字符串#
模型返回的参数在这里:
toolCall.function.arguments它通常是 JSON 字符串,不是对象。
所以要先解析:
const args = JSON.parse(toolCall.function.arguments)这段在正式代码里要包 try/catch,不能默认模型永远返回合法 JSON。
3. assistant 的 tool_calls 消息要保留#
这句很容易被忽略:
messages.push(message)不管模型这轮是最终回答,还是请求工具调用,都要先把这条 assistant message 放进历史。
后面再追加工具结果:
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
})顺序不能乱。
4. 工具结果要控制长度#
天气工具返回很短,看不出问题。
如果工具是 read_file 或 exec_shell,结果可能非常长。直接塞回 messages,上下文很快就爆了。
后面我写 coding agent 时,就给读文件工具加了截断。比如文件超过一定字符数,只返回前面一部分,并提示内容被截断。
从天气工具到 Coding Agent#
天气工具跑通以后,我把工具换成了更接近开发场景的四个:
read_file(path)
write_file(path, content)
list_dir(path)
exec_shell(cmd)这就接近一个最小 coding agent 了。
它可以先列目录,再读文件;需要统计时,可以执行命令;最后把结果整理成自然语言。
比如任务是:
扫描某个目录下的 .tsx 文件,统计行数最多的 3 个文件。模型可能会走这个流程:
list_dir
-> exec_shell("wc -l ...")
-> 根据命令结果回答这里我第一次感觉到,Agent 不只是“会聊天的模型”。它开始能操作环境了。
但这也带来另一个问题:工具越真实,边界越重要。
exec_shell 不能随便执行所有命令。
write_file 不能随便写系统目录。
read_file 不能把超大文件完整塞回上下文。
这些限制不影响理解 Agent Loop,但会影响一个 Agent 能不能安全稳定地跑。
我现在怎么理解 Agent#
写完这个最小版本后,我现在会先把 Agent 理解成这几块:
LLM
tools
messages
loop
边界控制LLM 负责判断下一步。
tools 提供外部能力。
messages 保存过程。
loop 把模型和工具接起来。
边界控制决定它能做什么、不能做什么。
很多框架里的概念,其实都能在这个最小版本里找到起点。
memory 最开始就是 messages。
tool use 最开始就是工具定义和本地 handler。
planning 可以先理解成模型在每一轮里决定下一步。
eval 是为了确认修改以后,Agent 还能稳定完成固定任务。
先把这些写出来,再看框架,脑子里会有一个落点。
后面继续做什么#
我接下来不打算马上上很大的框架。
先继续补这几块:
streaming
context compact
provider adapter
eval
更真实的工具设计流式输出解决等待体验。
上下文压缩解决长任务里 messages 越来越大的问题。
Provider adapter 用来隔离不同模型接口的差异。
Eval 用来检查这个 agent 有没有被自己改坏。
这些东西看起来没有那么炫,但对我这种从前端往 AI Agent 方向转的人来说,比较踏实。
我现在的学习策略是:先把一条最小链路写清楚,再往上加能力。每加一层,都知道它解决的具体问题。