开头的一个问题
"如果不能用 70 行代码写出一个 agent,就不算真的懂 agent 原理。"
这句话是我一直放在桌面上的一句提醒。现在各种 agent 框架满天飞——LangChain、CrewAI、AutoGen、Mastra……工具越来越多,但有一个问题始终没有被回答清楚:剥掉框架之后,agent 的核心到底长什么样?
这篇文章就是来回答这个问题的。我写了一个开源项目 mini-pi-agent,用 ~200 行 TypeScript,不依赖任何 agent SDK,手写一个真正能联网、能调用工具、能多轮对话的 agent。
不是玩具 demo,是真的接了 DeepSeek API、能读写文件、能查时间、能在命令行里一问一答的那种。
先看效果
装好依赖、配好 API key 之后,运行起来就是这样:
User: 帮我查一下现在几点
Assistant: Let me check the current time for you.
tool -> get_current_time: {"timezone":"Asia/Shanghai"}
Assistant: 现在是 2025-...
User: 帮我读一下 package.json 的内容
Assistant: Let me read that file for you.
tool -> read_file: {"file_path":"package.json"}
Assistant: package.json 的内容是...
User: exit
模型自己决定要不要用工具、用哪个、传什么参数。这就是 agent。
agent 的本质:一个 while 循环
很多人觉得 agent 很神秘,其实它的核心控制流用一个图就能画清楚:
用户输入
|
v
把 user 消息推进对话历史
|
v
-- agentLoop --
| 调 callLLM(带上所有工具的定义)
| |
| v
| 模型这次要不要调用工具?
| |
| +-- 不要 --> 打印答案,本轮结束
| |
| +-- 要
| |
| v
| 逐个 executeTool 执行
| |(执行失败也转成文本,不崩)
| v
| 把工具结果作为 tool 消息推回历史
| |
| +-- 带着结果再调一次 ---
+---------------------------
翻译成人话:
- 模型要工具 -> 执行 -> 把结果喂回去 -> 再问模型
- 模型不要工具 -> 说明它给出了最终答复 -> 循环结束
等到哪天不再需要工具,循环自然停下来。 没有状态机,没有调度器,没有中间件链路,就是一个 while。
代码结构:一个文件,五层
整个 agent 全部写在 agent_med.ts 这一个文件里,自包含,不 import 项目内任何本地文件。单独把这一个文件拿走,配一把 API key,就能跑。
从上到下分成五层:
| 层 | 内容 | 作用 |
|---|---|---|
| 内部类型 | Tool / toolCall / Message / CompletionRequest / CompletionResponse | agent 统一的数据形状,与外部 API 无关 |
| 外部类型 | OpenAiToolCall / OpenAiResponse | 专门描述 DeepSeek 返回的原始 JSON |
| 工具表 | Tools | 3 个工具的 JSON Schema,会交给模型看 |
| 格式翻译 | toOpenAiMessages / toOpenAiTools / mapToolCall / fromOpenAiResponse | 内部形状 <--> OpenAI 兼容 JSON 的双向转换 |
| 核心逻辑 | callLLM / executeTool / agentLoop / main | 调用模型、执行工具、循环、交互入口 |
一句话概括它的本质:一个 while 循环,加一层格式翻译。
核心代码拆解
1. 类型定义:内外分离
// 内部统一的 Message,跟外部 API 长什么样完全无关
export type Message =
| { role: "system"; content: string }
| { role: "assistant"; content: string; toolCalls?: toolCall[] }
| { role: "user"; content: string }
| { role: "tool"; toolCallId: string; content: string; isError?: boolean }
``+
注意 `tool` 消息带了 `toolCallId`——这是它能跟原始的工具调用对应上的关键。漏了这个 id,模型就不知道这个结果是对哪次工具调用的回复。
### 2. 工具表:模型的能力边界
```typescript
const Tools: Tool[] = [
{
name: "read_file",
description: "Read the content of a file",
parameter: {
type: "object",
properties: { file_path: { type: "string" } },
required: ["file_path"]
} as TSchema
},
// ... write_file, get_current_time
]
每个工具的参数用 JSON Schema 描述,标了 required。模型就是读这份 schema,来决定"调哪个工具、必须给哪些参数"——它的"能力边界"完全由这张表定义。
想给 agent 加新能力?往这张表里加一行,再在 executeTool 里加一个分支,就完了。
3. 格式翻译层
function toOpenAiMessages(messages: Message[]) {
return messages.map((m) => {
switch (m.role) {
case "tool": return {
...m, role: "tool",
tool_call_id: m.toolCallId,
content: m.isError ? `[ERROR] ${m.content}` : m.content
}
case "assistant": return {
role: "assistant",
content: m.content ?? "",
...(m.toolCalls?.length ? {
tool_calls: m.toolCalls.map(tc => ({
id: tc.id, type: "function",
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
}))
} : {})
}
// ...
}
})
}
内部循环从头到尾只认自己的 Message。OpenAI 兼容格式的长相(tool_calls、arguments 是字符串、tool_call_id……)全部在翻译层被消化掉。想换供应商,只动翻译层。
4. 核心循环
async function agentLoop(runMessages: Message[]): Promise<void> {
while (true) {
const response = await callLLM({
model: "deepseek-flash",
messages: runMessages,
tools: Tools
})
if (response.message.content.trim())
console.log(`Assistant: ${response.message.content}`)
runMessages.push(response.message)
const toolCalls = response.message.toolCalls || []
if (toolCalls.length === 0) break // 模型不再要工具 -> 结束
for (const tc of toolCalls) {
let result: string
try {
result = await executeTool(tc.name, tc.arguments)
} catch (err) {
result = `ERROR: ${err.message}`
}
runMessages.push({ role: "tool", toolCallId: tc.id, content: result })
}
}
}
注意几个关键点:
- 工具失败不炸进程:
executeTool抛出的异常被try/catch接住,转成ERROR: ...文本喂回给模型,让对话能自我纠错、继续下去 - 工具结果统一为字符串:成功与否都返回文本,方便直接塞进
tool消息 - 历史跨轮持久化:
messages数组的生命周期覆盖整个进程,而不是"处理一次输入"
5. 真正的网络请求
export async function callLLM(req: CompletionRequest): Promise<CompletionResponse> {
const body = {
model: req.model,
messages: toOpenAiMessages(req.messages),
tools: req.tools?.length ? toOpenAiTools(req.tools) : undefined,
stream: false
}
const res = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.DEEPSEEK_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
const text = await res.text()
if (!res.ok) throw new Error(`DeepSeek ${res.status}: ${text}`)
return fromOpenAiResponse(JSON.parse(text) as OpenAiResponse)
}
这里有一个容易踩坑的点:先判 res.ok,再解析 body。失败响应和成功响应的 JSON 形状完全不同,顺序反了会在很远的地方炸出一个看不懂根因的空指针错误。
一个请求的完整生命周期
以「帮我查一下现在几点」为例:
- 入口:
main()读到这句话,push 一条user消息进messages,调用agentLoop(messages) - 翻译:
toOpenAiMessages/toOpenAiTools把内部的Message[]和工具定义翻译成 DeepSeek 认识的 JSON - 请求:
fetch发给 DeepSeek,先取text()、判断res.ok,成功才继续 - 回译:
fromOpenAiResponse把外部响应翻译回统一的CompletionResponse,带回toolCalls: [{ name: "get_current_time", arguments: { timezone: "..." } }] - 执行:
agentLoop逐个调executeTool,真正算出时间 - 再问:循环回到第 2 步,这次历史里多了工具结果。模型读到时间后不再调用工具,直接给出答复——循环结束
离线 mock:验证循环本身的正确性
项目里还有一个 agent_mock.ts,跟 agent_med.ts 的 agentLoop/executeTool/Tools 几乎一模一样,唯一的区别是 callLLM 换成了离线 mock——不连网络,根据"历史里已经有几条 assistant 消息"直接算出该说什么。
npx tsx agent_mock.ts
不需要任何 key 就能跑。这个对照本身就是这个项目最想讲清楚的一件事:agent 的核心循环跟"到底连的是哪个模型、走不走网络"完全无关——agentLoop 不用改一行,换掉 callLLM 就能在"真实调用"和"离线跑通"之间切换。
快速开始
git clone https://github.com/your-repo/mini-pi-agent.git
cd mini-pi-agent
npm install
不想配 key,先看看循环本身对不对:
npx tsx agent_mock.ts
接真实 DeepSeek:
export DEEPSEEK_API_KEY="sk-你的key"
npx tsx min_executable_demo/agent_med.ts
或者建一个 .env(内容抄 .env.example),程序会自动读取。
实现时要注意的几个坑
这些是让实现"能跑通"而不只是"能编译"的关键决定:
1. 网络请求先检查 res.ok,再解析响应体
不能假设一次 HTTP 调用一定成功。失败时对方返回的错误 JSON 跟成功响应的形状完全不同,不做判断会在很远的地方炸出一个看不懂根因的空指针错误。
2. 避免用 any 接外部数据
一旦某个变量是 any,顺着它算出来的所有东西都会失去类型检查。对象结构错误、字段拼错、漏掉字段这些本该被拦下来的问题,全都会漏过去。
3. 跨供应商格式转换时,字段不能漏
比如把内部的工具调用转成 OpenAI 兼容格式时,每一项都需要带上 id。后续的工具执行结果要靠这个 id 才能跟原始调用对应上。漏了不会在转换那一步报错,只会在对方接口那边被拒绝。
4. 接住异常之后要真的处理
catch 里如果只是 throw 出去,等于没加这层保护。应该把错误转成能重新喂回给模型的信息,让对话继续,而不是让整个进程崩溃。
5. 多轮对话的历史要跨请求持久化
负责"这一次输入"的函数不该自己从零创建消息数组。累积对话历史的那数组,生命周期要覆盖"整个程序运行期间",而不是"处理一次输入"。否则每轮都会丢掉之前的上下文。
技术栈
- 运行:Node.js +
tsx(直接跑 TypeScript) - 类型:TypeScript,工具的
parameter用typebox的TSchema描述 - 依赖:仅
typebox;其余全是 Node 内置(readline、node:fs/promises、fetch) - 模型:DeepSeek(OpenAI 兼容接口),当前调用
deepseek-flash
写在最后
这个项目的出发点很简单:把 agent 的核心原理压缩进一个文件里,看看到底能不能讲清楚。
如果你也在学习 agent,想理解 tool-calling 的底层机制,或者想从零手写一个不依赖框架的 agent——这个项目应该对你有帮助。
项目地址:mini-pi-agent
欢迎 star、提 issue、一起讨论。
更多内容请访问我的个人网站:your-site.com