新闻详情

新闻详情

首页 / 资讯中心 / 详情

200行代码写一个agent

发布时间:2026/9/28 20:54:41来源:尧图网络
200行代码写一个agent
开头的一个问题“如果不能用 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/CompletionResponseagent 统一的数据形状与外部 API 无关外部类型OpenAiToolCall/OpenAiResponse专门描述 DeepSeek 返回的原始 JSON工具表Tools3 个工具的 JSON Schema会交给模型看格式翻译toOpenAiMessages/toOpenAiTools/mapToolCall/fromOpenAiResponse内部形状 – OpenAI 兼容 JSON 的双向转换核心逻辑callLLM/executeTool/agentLoop/main调用模型、执行工具、循环、交互入口一句话概括它的本质一个 while 循环加一层格式翻译。核心代码拆解1. 类型定义内外分离// 内部统一的 Message跟外部 API 长什么样完全无关exporttypeMessage|{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.工具表模型的能力边界typescriptconstTools:Tool[][{name:read_file,description:Read the content of a file,parameter:{type:object,properties:{file_path:{type:string}},required:[file_path]}asTSchema},// ... write_file, get_current_time]每个工具的参数用 JSON Schema 描述标了required。模型就是读这份 schema来决定调哪个工具、必须给哪些参数——它的能力边界完全由这张表定义。想给 agent 加新能力往这张表里加一行再在executeTool里加一个分支就完了。3. 格式翻译层functiontoOpenAiMessages(messages:Message[]){returnmessages.map((m){switch(m.role){casetool:return{...m,role:tool,tool_call_id:m.toolCallId,content:m.isError?[ERROR]${m.content}:m.content}caseassistant: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. 核心循环asyncfunctionagentLoop(runMessages:Message[]):Promisevoid{while(true){constresponseawaitcallLLM({model:deepseek-flash,messages:runMessages,tools:Tools})if(response.message.content.trim())console.log(Assistant:${response.message.content})runMessages.push(response.message)consttoolCallsresponse.message.toolCalls||[]if(toolCalls.length0)break// 模型不再要工具 - 结束for(consttcoftoolCalls){letresult:stringtry{resultawaitexecuteTool(tc.name,tc.arguments)}catch(err){resultERROR:${err.message}}runMessages.push({role:tool,toolCallId:tc.id,content:result})}}}注意几个关键点工具失败不炸进程executeTool抛出的异常被try/catch接住转成ERROR: ...文本喂回给模型让对话能自我纠错、继续下去工具结果统一为字符串成功与否都返回文本方便直接塞进tool消息历史跨轮持久化messages数组的生命周期覆盖整个进程而不是处理一次输入5. 真正的网络请求exportasyncfunctioncallLLM(req:CompletionRequest):PromiseCompletionResponse{constbody{model:req.model,messages:toOpenAiMessages(req.messages),tools:req.tools?.length?toOpenAiTools(req.tools):undefined,stream:false}constresawaitfetch(https://api.deepseek.com/chat/completions,{method:POST,headers:{Authorization:Bearer${process.env.DEEPSEEK_API_KEY},Content-Type:application/json},body:JSON.stringify(body)})consttextawaitres.text()if(!res.ok)thrownewError(DeepSeek${res.status}:${text})returnfromOpenAiResponse(JSON.parse(text)asOpenAiResponse)}这里有一个容易踩坑的点先判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就能在真实调用和离线跑通之间切换。快速开始gitclone https://github.com/your-repo/mini-pi-agent.gitcdmini-pi-agentnpminstall不想配 key先看看循环本身对不对npx tsx agent_mock.ts接真实 DeepSeekexportDEEPSEEK_API_KEYsk-你的keynpx 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模型DeepSeekOpenAI 兼容接口当前调用deepseek-flash写在最后这个项目的出发点很简单把 agent 的核心原理压缩进一个文件里看看到底能不能讲清楚。如果你也在学习 agent想理解 tool-calling 的底层机制或者想从零手写一个不依赖框架的 agent——这个项目应该对你有帮助。项目地址mini-pi-agent欢迎 star、提 issue、一起讨论。更多内容请访问我的个人网站https://your-site.com
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Java单体养老系统源码解析:Spring Boot架构与数据库设计实战 2026/9/28 21:33:38

Java单体养老系统源码解析:Spring Boot架构与数据库设计实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
同步整流设计实战:从二极管到MOS管的效率提升与避坑指南 2026/9/28 21:33:31

同步整流设计实战:从二极管到MOS管的效率提升与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
MATLAB锁相环PLL仿真从原理到代码实现:二阶环路滤波器与参数整定 2026/9/28 21:33:23

MATLAB锁相环PLL仿真从原理到代码实现:二阶环路滤波器与参数整定

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
CANoe高效分析BLF文件:DBC加载、信号解析到Python自动化全流程 2026/9/28 21:33:17

CANoe高效分析BLF文件:DBC加载、信号解析到Python自动化全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
STM32硬件同步实现激光雷达与相机时间对齐的GAC-Mapping建图实践 2026/9/28 21:32:56

STM32硬件同步实现激光雷达与相机时间对齐的GAC-Mapping建图实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
RK3588 OpenCL硬件加速实战:视频处理算子性能对比与选型指南 2026/9/28 21:32:43

RK3588 OpenCL硬件加速实战:视频处理算子性能对比与选型指南

1. 为什么要在RK3588上折腾OpenCL硬件加速手里这块RK3588板子跑了大半年的视频处理流水线,从最早的纯CPU软解到后来接入MPP硬编硬解,再到最近把几个关键算子用OpenCL重写,一路踩坑下来最大的感受就是:这颗芯片的算力是够的&#x…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉