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

文章来源声明: 原文作者:用户97998480718; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689609382251315200; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

剥掉框架看本质,适合想搞懂 tool-calling 的开发者当作最小可运行参考,代码短、模板清晰,还能换掉 callLLM 离线验证循环,动手成本低。

开头的一个问题 -------

"如果不能用 70 行代码写出一个 agent,就不算真的懂 agent 原理。"

这句话是我一直放在桌面上的一句提醒。现在各种 agent 框架满天飞——LangChain、CrewAI、AutoGen、Mastra……工具越来越多,但有一个问题始终没有被回答清楚:剥掉框架之后,agent 的核心到底长什么样?

这篇文章就是来回答这个问题的。我写了一个开源项目 mini-pi-agent,用 ~200 行 TypeScript,不依赖任何 agent SDK,手写一个真正能联网、能调用工具、能多轮对话的 agent。

不是玩具 demo,是真的接了 DeepSeek API、能读写文件、能查时间、能在命令行里一问一答的那种。


先看效果

装好依赖、配好 API key 之后,运行起来就是这样:

<span>User:</span> 帮我查一下现在几点
<span>Assistant:</span> <span>Let</span> <span>me</span> check the current time <span>for</span> you.
tool -> get_current_time: {<span>"timezone"</span>:<span>"Asia/Shanghai"</span>}
<span>Assistant:</span> 现在是 <span>2025</span>-... 
<span>User:</span> 帮我读一下 package.json 的内容
<span>Assistant:</span> <span>Let</span> <span>me</span> read that file <span>for</span> you.
tool -> read_file: {<span>"file_path"</span>:<span>"package.json"</span>}
<span>Assistant:</span> package.json 的内容是...
<span>User:</span> <span>exit</span>

模型自己决定要不要用工具、用哪个、传什么参数。这就是 agent。


agent 的本质:一个 while 循环

很多人觉得 agent 很神秘,其实它的核心控制流用一个图就能画清楚:

用户输入
   <span>|</span>
   v
把 <span>user</span> 消息推进对话历史
   <span>|</span>
   v
<span>-- agentLoop --</span>
<span>|</span>  调 callLLM(带上所有工具的定义)
<span>|</span>        <span>|</span>
<span>|</span>        v
<span>|</span>   模型这次要不要调用工具?
<span>|</span>        <span>|</span>
<span>|</span>        <span>+</span><span>-- 不要 --> 打印答案,本轮结束</span>
<span>|</span>        <span>|</span>
<span>|</span>        <span>+</span><span>-- 要</span>
<span>|</span>            <span>|</span>
<span>|</span>            v
<span>|</span>       逐个 executeTool 执行
<span>|</span>            <span>|</span>(执行失败也转成文本,不崩)
<span>|</span>            v
<span>|</span>       把工具结果作为 tool 消息推回历史
<span>|</span>            <span>|</span>
<span>|</span>            <span>+</span><span>-- 带着结果再调一次 ---</span>
<span>+</span><span>---------------------------</span>

翻译成人话:

  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. 类型定义:内外分离

<span>// 内部统一的 Message,跟外部 API 长什么样完全无关</span>
<span>export</span> <span>type</span> <span>Message</span> =
    | { <span>role</span>: <span>"system"</span>; <span>content</span>: <span>string</span> }
    | { <span>role</span>: <span>"assistant"</span>; <span>content</span>: <span>string</span>; toolCalls?: toolCall[] }
    | { <span>role</span>: <span>"user"</span>; <span>content</span>: <span>string</span> }
    | { <span>role</span>: <span>"tool"</span>; <span>toolCallId</span>: <span>string</span>; <span>content</span>: <span>string</span>; isError?: <span>boolean</span> }
<span>``</span>+

注意 <span>`tool`</span> 消息带了 <span>`toolCallId`</span>——这是它能跟原始的工具调用对应上的关键。漏了这个 id,模型就不知道这个结果是对哪次工具调用的回复。

### <span>2.</span> 工具表:模型的能力边界

<span>``</span><span>`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
]
</span>

每个工具的参数用 JSON Schema 描述,标了 required。模型就是读这份 schema,来决定"调哪个工具、必须给哪些参数"——它的"能力边界"完全由这张表定义。

想给 agent 加新能力?往这张表里加一行,再在 executeTool 里加一个分支,就完了。

3. 格式翻译层

<span>function</span> <span>toOpenAiMessages</span>(<span>messages: Message[]</span>) {
    <span>return</span> messages.<span>map</span>(<span>(<span>m</span>) =></span> {
        <span>switch</span> (m.<span>role</span>) {
            <span>case</span> <span>"tool"</span>: <span>return</span> {
                ...m, <span>role</span>: <span>"tool"</span>,
                <span>tool_call_id</span>: m.<span>toolCallId</span>,
                <span>content</span>: m.<span>isError</span> ? <span>`[ERROR] <span>${m.content}</span>`</span> : m.<span>content</span>
            }
            <span>case</span> <span>"assistant"</span>: <span>return</span> {
                <span>role</span>: <span>"assistant"</span>,
                <span>content</span>: m.<span>content</span> ?? <span>""</span>,
                ...(m.<span>toolCalls</span>?.<span>length</span> ? {
                    <span>tool_calls</span>: m.<span>toolCalls</span>.<span>map</span>(<span><span>tc</span> =></span> ({
                        <span>id</span>: tc.<span>id</span>, <span>type</span>: <span>"function"</span>,
                        <span>function</span>: { <span>name</span>: tc.<span>name</span>, <span>arguments</span>: <span>JSON</span>.<span>stringify</span>(tc.<span>arguments</span>) }
                    }))
                } : {})
            }
            <span>// ...</span>
        }
    })
}

内部循环从头到尾只认自己的 Message。OpenAI 兼容格式的长相(tool_calls、arguments 是字符串、tool_call_id……)全部在翻译层被消化掉。想换供应商,只动翻译层。

4. 核心循环

<span>async</span> <span>function</span> <span>agentLoop</span>(<span>runMessages: Message[]</span>): <span>Promise</span><<span>void</span>> {
    <span>while</span> (<span>true</span>) {
        <span>const</span> response = <span>await</span> <span>callLLM</span>({
            <span>model</span>: <span>"deepseek-flash"</span>,
            <span>messages</span>: runMessages,
            <span>tools</span>: <span>Tools</span>
        })
        <span>if</span> (response.<span>message</span>.<span>content</span>.<span>trim</span>())
            <span>console</span>.<span>log</span>(<span>`Assistant: <span>${response.message.content}</span>`</span>)
        runMessages.<span>push</span>(response.<span>message</span>)

        <span>const</span> toolCalls = response.<span>message</span>.<span>toolCalls</span> || []
        <span>if</span> (toolCalls.<span>length</span> === <span>0</span>) <span>break</span>  <span>// 模型不再要工具 -> 结束</span>

        <span>for</span> (<span>const</span> tc <span>of</span> toolCalls) {
            <span>let</span> <span>result</span>: <span>string</span>
            <span>try</span> {
                result = <span>await</span> <span>executeTool</span>(tc.<span>name</span>, tc.<span>arguments</span>)
            } <span>catch</span> (err) {
                result = <span>`ERROR: <span>${err.message}</span>`</span>
            }
            runMessages.<span>push</span>({ <span>role</span>: <span>"tool"</span>, <span>toolCallId</span>: tc.<span>id</span>, <span>content</span>: result })
        }
    }
}

注意几个关键点:

  • 工具失败不炸进程:executeTool 抛出的异常被 try/catch 接住,转成 ERROR: ... 文本喂回给模型,让对话能自我纠错、继续下去
  • 工具结果统一为字符串:成功与否都返回文本,方便直接塞进 tool 消息
  • 历史跨轮持久化:messages 数组的生命周期覆盖整个进程,而不是"处理一次输入"

5. 真正的网络请求

<span>export</span> <span>async</span> <span>function</span> <span>callLLM</span>(<span>req: CompletionRequest</span>): <span>Promise</span><<span>CompletionResponse</span>> {
    <span>const</span> body = {
        <span>model</span>: req.<span>model</span>,
        <span>messages</span>: <span>toOpenAiMessages</span>(req.<span>messages</span>),
        <span>tools</span>: req.<span>tools</span>?.<span>length</span> ? <span>toOpenAiTools</span>(req.<span>tools</span>) : <span>undefined</span>,
        <span>stream</span>: <span>false</span>
    }
    <span>const</span> res = <span>await</span> <span>fetch</span>(<span>"https://api.deepseek.com/chat/completions"</span>, {
        <span>method</span>: <span>"POST"</span>,
        <span>headers</span>: {
            <span>"Authorization"</span>: <span>`Bearer <span>${process.env.DEEPSEEK_API_KEY}</span>`</span>,
            <span>"Content-Type"</span>: <span>"application/json"</span>
        },
        <span>body</span>: <span>JSON</span>.<span>stringify</span>(body)
    })
    <span>const</span> text = <span>await</span> res.<span>text</span>()
    <span>if</span> (!res.<span>ok</span>) <span>throw</span> <span>new</span> <span>Error</span>(<span>`DeepSeek <span>${res.status}</span>: <span>${text}</span>`</span>)
    <span>return</span> <span>fromOpenAiResponse</span>(<span>JSON</span>.<span>parse</span>(text) <span>as</span> <span>OpenAiResponse</span>)
}

这里有一个容易踩坑的点:先判 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 <span>clone</span> https://github.com/your-repo/mini-pi-agent.git
<span>cd</span> mini-pi-agent
npm install

不想配 key,先看看循环本身对不对:

npx tsx agent_mock.ts

接真实 DeepSeek:

<span>export</span> DEEPSEEK_API_KEY=<span>"sk-你的key"</span>
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