中文资讯 掘金 AI Sun, 27 Sep 2026 正文 ok 941 words

用约 200 行代码手写可运行的 AI Agent:不依赖框架,理解 tool-calling 原理

英文标题:200 行代码写一个能跑的 AI Agent:不依赖任何框架,只靠 tool-calling 原理

本文通过开源项目 mini-pi-agent 展示了如何仅用约 200 行 TypeScript、无需 Agent SDK,实现一个可联网、可调用工具并支持多轮对话的 AI Agent。文章指出,Agent 的核心本质是一个持续调用 LLM、执行工具并回传结果的 `while` 循环,而非复杂的状态机或框架。项目通过内部统一数据结构与 OpenAI 兼容 API 格式转换层解耦模型供应商,并接入 DeepSeek API 实现真实运行。对开发者而言,该实现提供了理解 tool-calling、错误处理、会话历史持久化及跨供应商适配的最小可运行参考。
打开原文相关 3 · 机会 2
media3.2 KB media589 B media4.8 KB

中文

开头的一个问题

“如果不能用 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

| 调 callLLM(带上所有工具的定义) | | | v | 模型这次要不要调用工具? | | | + | | | + | | | v | 逐个 executeTool 执行 | |(执行失败也转成文本,不崩) | v | 把工具结果作为 tool 消息推回历史 | | | + +

翻译成人话:

  1. 模型需要工具 -> 执行工具 -> 把结果喂回模型 -> 再次询问模型。
  2. 模型不需要工具 -> 说明它已给出最终答复 -> 循环结束。

**等到某一轮不再需要工具时,循环自然停下来。**没有状态机,没有调度器,没有中间件链路,就是一个 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. 类型定义:内外分离

typescript 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. 格式翻译层

typescript 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. 核心循环

typescript async function agentLoop(runMessages: Message[]): Promise { 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. 真正的网络请求

typescript export async function callLLM(req: CompletionRequest): Promise { 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 形状完全不同;如果顺序反了,可能会在很远的地方抛出一个难以理解根因的空指针错误。


一个请求的完整生命周期

以“帮我查一下现在几点”为例:

  1. 入口:main() 读到这句话后,向 messages 中 push 一条 user 消息,并调用 agentLoop(messages)。
  2. 翻译:toOpenAiMessages / toOpenAiTools 将内部的 Message[] 和工具定义翻译成 DeepSeek 能识别的 JSON。
  3. 请求:通过 fetch 发给 DeepSeek,先读取 text() 并判断 res.ok,成功后才继续处理。
  4. 回译:fromOpenAiResponse 将外部响应翻译回统一的 CompletionResponse,带回 toolCalls: [{ name: "get_current_time", arguments: { timezone: "..." } }]。
  5. 执行:agentLoop 逐个调用 executeTool,实际计算当前时间。
  6. 再问:循环回到第 2 步。这一次历史中已经有工具结果,模型读到时间后不再调用工具,直接给出答复,循环结束。

离线 mock:验证循环本身的正确性

项目中还有一个 agent_mock.ts,与 agent_med.ts 的 agentLoop / executeTool / Tools 几乎一模一样,唯一的区别是将 callLLM 换成了离线 mock——不连接网络,而是根据“历史中已经有几条 assistant 消息”直接计算这轮应该说什么。

bash npx tsx agent_mock.ts

不需要任何 key 就能运行。这个对照本身正是该项目最想讲清楚的一点:agent 的核心循环与“连接的是哪个模型、是否走网络”完全无关——agentLoop 不需要改一行,只要替换 callLLM,就能在“真实调用”和“离线跑通”之间切换。


快速开始

bash git clone https://github.com/your-repo/mini-pi-agent.git cd mini-pi-agent npm install

如果不想配置 key,可以先确认循环本身是否正确:

bash npx tsx agent_mock.ts

接入真实 DeepSeek:

bash 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

English / 原文

开头的一个问题

"如果不能用 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

|  调 callLLM(带上所有工具的定义)
|        |
|        v
|   模型这次要不要调用工具?
|        |
|        +
|        |
|        +
|            |
|            v
|       逐个 executeTool 执行
|            |(执行失败也转成文本,不崩)
|            v
|       把工具结果作为 tool 消息推回历史
|            |
|            +
+

翻译成人话:

  1. 模型要工具 -> 执行 -> 把结果喂回去 -> 再问模型
  2. 模型不要工具 -> 说明它给出了最终答复 -> 循环结束

等到哪天不再需要工具,循环自然停下来。 没有状态机,没有调度器,没有中间件链路,就是一个 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. 类型定义:内外分离

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 形状完全不同,顺序反了会在很远的地方炸出一个看不懂根因的空指针错误。


一个请求的完整生命周期

以「帮我查一下现在几点」为例:

  1. 入口 :main() 读到这句话,push 一条 user 消息进 messages,调用 agentLoop(messages)
  2. 翻译 :toOpenAiMessages / toOpenAiTools 把内部的 Message[] 和工具定义翻译成 DeepSeek 认识的 JSON
  3. 请求 :fetch 发给 DeepSeek,先取 text()、判断 res.ok,成功才继续
  4. 回译 :fromOpenAiResponse 把外部响应翻译回统一的 CompletionResponse,带回 toolCalls: [{ name: "get_current_time", arguments: { timezone: "..." } }]
  5. 执行 :agentLoop 逐个调 executeTool,真正算出时间
  6. 再问 :循环回到第 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