从零手写智能体:从大模型HTTP原始调用到工具调用实战

发布时间:2026/10/20 5:01:55
从零手写智能体:从大模型HTTP原始调用到工具调用实战 先说明一下这个系列我在做的时候给自己立了一条规矩——不动用任何现成的智能体框架不引入任何封装好的SDK完全从最底层、最原始的大模型调用开始写。很多人做智能体第一反应就是装框架、拖组件、接插件结果做了半天连模型到底是怎么被调起来的都说不清楚。这不是坏事但出了问题也很难排查。所以我决定把这个项目直接摊开来写第一天就老实交代JchatMind智能体最初的样子其实就是一段发往大模型API的HTTP请求外加一个循环读取返回内容的终端脚本。这篇就是给你完整还原这个过程包括我从零开始写的每一步代码、踩过的每个坑以及为什么这么设计。这个系列适合两类人看一类是刚接触大模型应用开发、想弄清楚智能体到底由什么组成的初学者另一类是已经用过各种框架、但想回头把地基夯实遇到问题不至于抓瞎的开发者。1. 先讲清楚JchatMind的第一天为什么非要退回到原始时代很多教程会教你直接上手某个框架把大模型的调用封装成一行函数。但我的观点很朴素你要做一个智能体首先得知道一次大模型调用在机器层面上究竟发生了什么否则后面所有的记忆管理、工具调用、流程编排都是踩在沙子上盖楼。1.1 智能体的地基其实只有三件事我理解的智能体剥掉所有花哨概念后只剩下三块模型调用让大模型根据输入文本生成输出文本。上下文管理把之前的聊天内容作为输入的一部分让模型看起来记住了对话。工具调用让模型在需要的时候输出一个我要执行某个函数的指令程序去执行再把结果喂回给模型。这三件事没有任何一件是非要用框架才能完成的。第一天的JchatMind我要求自己连第一步模型调用都必须能徒手写出来——精确到HTTP请求长什么样、响应怎么解析。1.2 框架隐藏了太多本该知道的细节举个例子很多人第一次用封装好的大模型库只是传了一个prompt就拿到了结果。但模型到底是怎么看懂system message的temperature调到0.9和0.2有什么差别为什么返回内容有时候是断裂的流式输出时data: [DONE]到底代表什么这些细节框架不会替你想但它们恰恰决定了智能体的稳定性。从原始调用开始并不是为了炫技而是为了给后续排查问题留一条清晰的逻辑链。我第一天踩的几个坑后面会挨个展开全部都是底层细节没吃透导致的。1.3 什么时候才轮到框架出场我也不是在否定框架。当项目跑到一定规模——要对接多个模型、要编排复杂工作流、要接入外部知识库——这时候引入框架节约成本是合理的。但前提是你已经对一次裸调用了如指掌。这样框架出问题的时候你能判断出是框架的问题还是模型本身的问题。第一天我先把这层底子打牢。2. 最原始调用到底长什么样一次HTTP请求的内在结构先建立一个认知目前主流的大模型API本质上就是一个HTTP POST接口。你把一段文本通过JSON格式发过去服务端把模型生成的结果通过JSON返回来。看起来高深莫测拆开其实非常直白。2.1 请求体里的每个字段都是什么意思大模型API的请求地址通常形如POST /v1/chat/completions请求头需要两个东西Content-Type: application/json以及Authorization: Bearer 你的API密钥。请求体核心字段大概就这几个字段作用我的建议model指定用哪个模型根据任务选对话类选通用旗舰结构化任务选支持严格JSON输出的messages对话消息列表包含角色和内容核心字段智能体的所有记忆都体现在这里temperature控制随机性0到1或更高写代码用0.2以下创意写作可以0.7以上max_tokens限制本次最大生成长度不设可能超出预期设了能控制成本stream是否流式返回做对话产品建议true做批量处理建议false2.2 messages数组智能体记忆的真相这个设计很多人第一次接触会懵。它不是只把用户最新一句话发给模型而是要发给模型一个数组[ {role: system, content: 你是一个智能助手名字叫JchatMind。}, {role: user, content: 你好请介绍一下自己}, {role: assistant, content: 你好我是JchatMind一个智能体助手。}, {role: user, content: 你刚才说你叫什么} ]用大白话讲你把对话历史整个摊开在模型面前模型读一遍然后接着往下写。它没有真正记住任何东西它只是每次都在看完聊天记录后接着回应。这带来两个很重要的推论上下文越长花的token越多速度越慢。程序如果把无关紧要的历史全塞进去模型的表现会被稀释。2.3 角色分工system、user、assistant分别管什么这三个角色相当于一个团队的三种分工system给模型定基调、立规矩。它是幕后的导演用户看不到但对行为影响最大。user用户的发言。assistant模型自己的历史回复。在连续对话中必须把它带上模型才知道自己之前说过什么。我在第一天犯过一个典型错误连续对话没带assistant的历史回复导致模型每次回答都像失忆一样。后面排查到原因才真正想通这个数组的设计逻辑。3. 不借助任何SDK用curl把第一句话送给模型理论说完了直接动手。第一步我不写代码先用最原始的工具——curl——来验证模型接口通不通。这个过程不需要任何依赖是排查连通性最快的方法。3.1 准备环境变量别把密钥写死在代码里我在项目根目录建了一个.env文件把API密钥和模型名放进去export JCHAT_API_KEY你的密钥 export JCHAT_MODELyour-model-name export JCHAT_API_BASEhttps://your-api-endpoint.example.com/v1/chat/completions把密钥放到环境变量里是个好习惯。不然代码写错提交到仓库密钥就泄露了。后面写Python时也需要读取这两个变量。3.2 真正发出去的第一条请求在终端执行curl $JCHAT_API_BASE \ -H Content-Type: application/json \ -H Authorization: Bearer $JCHAT_API_KEY \ -d { model: $JCHAT_MODEL, messages: [ {role: system, content: 你是JchatMind一个简洁、准确的智能助手。}, {role: user, content: 请用一句话说明你的身份。} ], temperature: 0.3, max_tokens: 200 }等几秒会看到返回的JSON长这样{ id: chatcmpl-xxx, object: chat.completion, choices: [ { index: 0, message: { role: assistant, content: 我是JchatMind一个由简化流程驱动的智能助手。 }, finish_reason: stop } ], usage: { prompt_tokens: 28, completion_tokens: 15, total_tokens: 43 } }看到这个返回说明最底层的通信已经打通了。我把这个时刻视为JchatMind的开机自检。3.3 响应字段逐个拆解choices[0].message.content模型生成的正文这是主角。choices[0].finish_reason为什么结束。stop表示自然结束length表示触达max_tokens上限tool_calls表示模型想调用工具。usage.total_tokens本次调用消耗的token总量。做成本优化的时候盯这一个字段就够了。有个坑值得提前说如果你收到的content是null但finish_reason是tool_calls说明模型没有直接回答而是想调用工具。这种返回值我第一天没处理直接解析content就报了空指针后面专门写了工具调用模块才解决。4. 用代码完成对话闭环从一次请求到连续对话curl能通说明地基可以开工了。但JchatMind不能靠手敲curl活着我需要把它变成可复用的Python代码。这个阶段我不装任何第三方大模型SDK只依赖requests库。4.1 最小的调用函数入参是消息数组出参是模型回复import os import json import requests API_BASE os.getenv(JCHAT_API_BASE) API_KEY os.getenv(JCHAT_API_KEY) MODEL_NAME os.getenv(JCHAT_MODEL) def chat_once(messages, temperature0.3, max_tokens1024): headers { Content-Type: application/json, Authorization: fBearer {API_KEY}, } payload { model: MODEL_NAME, messages: messages, temperature: temperature, max_tokens: max_tokens, } resp requests.post(API_BASE, headersheaders, jsonpayload, timeout60) resp.raise_for_status() data resp.json() return data[choices][0][message][content]这个函数非常简单但它划出了整条链路的边界入参是message数组出参是模型文本。后续所有智能体能力——记忆、工具调用、流程控制——都在这个函数外面加逻辑不会污染它的本质。4.2 连续对话的关键把每轮内容都追加到messages里我一开始天真地以为连续对话就是把用户的新问题追加进数组。结果模型不但忘记了自己说过什么连用户第一句话都记不住。原因是我只追加了user消息没把assistant的回复也追加进去。正确做法是这样messages [ {role: system, content: 你是JchatMind回复简洁准确不编造信息。}, ] while True: user_input input(你: ) if user_input.lower() in (exit, quit): break messages.append({role: user, content: user_input}) reply chat_once(messages) print(fJchatMind: {reply}) messages.append({role: assistant, content: reply})这段代码就是JchatMind第一天的对话闭环输入、调用、输出、回填历史。4.3 上下文无限膨胀的隐患跑着跑着我发现一个新问题对话越长每次请求越慢费用越高。原因很简单——messages被撑得越来越大。模型每次都要把全部历史重新读一遍token成本线性上涨。第一天的简陋策略是截断def trim_messages(messages, max_messages20): if len(messages) max_messages: return messages return [messages[0]] messages[-(max_messages - 1):]默认保留system消息然后只保留最近若干轮对话。这个策略很粗糙但它让我理解了智能体的记忆管理本质上是一个在信息和成本之间做取舍的动态过程。后续版本会换成token数裁剪但第一天的思路是正确的。4.4 流式输出让模型边说边吐而不是憋完才说对话产品不做流式输出体验会很差。用户发一句话页面要转圈好几秒才一次性出结果。流式输出是让模型生成一段就传一段终端或前端就能边收边显示。启用流式很简单把stream设为truedef chat_stream(messages, temperature0.3): headers { Content-Type: application/json, Authorization: fBearer {API_KEY}, } payload { model: MODEL_NAME, messages: messages, temperature: temperature, stream: True, } with requests.post(API_BASE, headersheaders, jsonpayload, streamTrue, timeout120) as resp: resp.raise_for_status() for line in resp.iter_lines(): if line: line line.decode(utf-8) if line.startswith(data:): data_str line[5:].strip() if data_str [DONE]: break chunk json.loads(data_str) delta chunk[choices][0][delta].get(content, ) if delta: print(delta, end, flushTrue)这段代码的核心是处理SSEServer-Sent Events流。每一行以data:开头后面跟着一段JSON。[DONE]是结束标记。我第一天在这里栽了个跟头后面会详细说。5. 给JchatMind装上手和眼睛最原始版本的工具调用设计对话闭环打通之后JchatMind已经能聊了。但智能体和聊天机器人的最大区别在于智能体能调用外部工具改变真实世界的状态。比如查天气、算数学、读写文件。第一天我决定做一个极其粗糙但逻辑完整的工具调用原型。5.1 工具调用的本质模型只出指令程序来执行很多人误以为大模型调用工具是模型直接联网执行操作。实际上模型根本不会执行任何东西它只是输出一段结构化文本描述我建议调用哪个函数、传什么参数。真正动手的是你的程序。打个比方模型是个只会发号施令的顾问它写一张便签说请给我拿一杯水温度70度你作为执行者读便签、去倒水、把水端回来再告诉顾问水来了。整个过程顾问并没有真实接触到水。5.2 用一个字典管理可用的工具第一天我先给JchatMind定义了三个最简单的工具函数并把它们放进一个字典def get_current_time(): import datetime return datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) def calculate(expression: str): try: return str(eval(expression, {__builtins__: {}}, {})) except Exception as e: return f计算错误: {e} def get_weather(city: str): fake_weather {北京: 晴22度, 上海: 多云25度, 广州: 小雨28度} return fake_weather.get(city, f没有{city}的天气数据) TOOLS { get_current_time: {desc: 获取当前日期和时间无参数, func: get_current_time}, calculate: {desc: 计算数学表达式参数expression为字符串, func: calculate}, get_weather: {desc: 查询城市天气参数city为城市名, func: get_weather}, }注意这里calculate用了eval只是为了第一天演示方便生产环境绝不能这么干会被注入恶意代码。后面我会换成语法树解析。5.3 让模型学会开口要工具我不想接复杂的function calling协议第一天的做法非常原始把工具说明写进system prompt并严格要求模型按固定JSON格式输出。TOOL_PROMPT 你可以使用以下工具 - get_current_time: 获取当前日期和时间无参数 - calculate: 计算数学表达式参数 expression: string - get_weather: 查询城市天气参数 city: string 如果需要使用工具请严格输出如下JSON格式不要输出任何其他文字 {tool: 工具名, args: {参数名: 参数值}} 如果没有合适的工具直接正常回答用户。 调用的时候把TOOL_PROMPT拼在system消息后面messages [ {role: system, content: BASE_SYSTEM \n TOOL_PROMPT}, {role: user, content: 现在几点了}, ] reply chat_once(messages, temperature0.1) print(reply)温度我特意调低到0.1因为第一步只要求模型输出稳定的JSON格式不能让它的创作欲望捣乱。5.4 解析JSON指令、执行函数、把结果回喂给模型下面这段是整个工具调用骨架最关键的部分import json def run_tool_once(messages): reply chat_once(messages, temperature0.1) try: tool_call json.loads(reply) tool_name tool_call[tool] tool_args tool_call.get(args, {}) if tool_name in TOOLS: result TOOLS[tool_name][func](**tool_args) messages.append({role: assistant, content: reply}) messages.append({role: user, content: f工具执行结果是: {result}。请基于这个结果回答用户的原问题。}) return chat_once(messages, temperature0.3) except json.JSONDecodeError: # 模型没输出JSON说明它选择了直接回答 return reply return reply流程展开就是四步调用模型得到回复文本。尝试把回复解析成JSON看看是不是工具调用指令。如果解析成功按名字找到工具函数实参传进去拿到真实结果。把工具执行结果作为一条user消息追加到上下文里再次调用模型让它基于真实结果组织最终回复。这一步做完JchatMind第一次动手做了事。它不再是纯嘴炮至少能报时间、算算术、报天气了。实现完之后在终端试了一下输入北京天气怎么样程序输出的链路一清二楚模型回复第一轮: {tool: get_weather, args: {city: 北京}} 工具执行结果: 晴22度 JchatMind最终回复: 北京今天是晴天气温22度适合出门。5.5 这个极简设计的边界在哪里我必须诚实地说这个版本的工具调用非常脆弱。比如模型输出JSON时带了任何多余文字json.loads就会失败。多轮工具调用无法自动接力比如算一下今天到年底有多少天需要先取日期再计算链式调用没做。工具函数如果内部抛异常处理得还很粗糙。但这些不足是正常的。第一天的目标是打通模型出指令—程序执行—结果回喂这条链路不是做工业级产品。有了这条主干道后面往上面加链式调用稳健解析异常恢复才有地方挂。6. 第一天踩过的三个坑完整的排查链路写代码不可能不踩坑。这一节我把第一天里印象最深、也最具代表性的三个问题完整还原包括现象、排查过程和最后的修复方案。这些坑都是不亲手从底层写一遍很难碰到的经验。6.1 流式输出解析错位整个data块被当成一段JSON解析现象调用chat_stream时输出了一段完整的JSON解析错误堆栈。仔细看内容是Expected value: line 1 column 1后面跟了一整段包含data:前缀的文本。排查过程我先在iter_lines里打印每一行原始内容看到形如data: {choices: [{delta: {content: 你}}]}这样的行。最初我直接line[5:]然后json.loads没有考虑这一行前面可能还有连续两个换行符导致的空行也没考虑[DONE]标记。调试发现最后的data: [DONE]被json.loads解析直接抛异常。修复把判断[DONE]放到json.loads之前并且用strip()去掉空白。if line.startswith(data:): data_str line[5:].strip() if data_str [DONE]: break chunk json.loads(data_str)这个坑的本质是我对流式协议SSE的边界格式不熟。框架把这一切都遮住了所以我从不后悔第一天从底层开始。6.2 上下文膨胀导致答非所问和请求超时现象连续对话20轮后模型回答开始变慢更严重的是会跑题有时候甚至重复之前的回答。排查过程我先打印了每次请求的messages长度和usage.total_tokens发现20轮之后messages已经堆了超过6000个token。大模型读入的上下文越长注意力被稀释得越严重。模型并不是无限聪明它只是在给定的一段文本内接续如果这段文本里有太多无关紧要的历史核心问题反而被淹没。另外请求体变大导致网络传输和模型处理时间变长终端表现就是卡顿。修复加了trim_messages截断函数保留system和最近10轮。并通过在system里加一句只依据最近的对话内容回答忽略与当前问题无关的早期内容让模型主动聚焦。这个坑让我意识到记忆管理不是一个存下来就完事的问题而是一个动态的、有损的压缩过程。智能体必须学会忘掉不重要的东西和人是类似的。6.3 温度参数导致的工具调用不稳定现象工具调用模块偶尔好用偶尔输出一堆散文完全不按JSON格式来。代码里明明已经写清楚了不要输出任何其他文字。排查过程我打印了temperature的值发现一开始用的0.7。温度高意味着随机性大模型在守规矩输出JSON和自由发挥之间摇摆。同样一条用户问题temperature0.1时稳如老狗temperature0.7时就开始发挥。我又试了把工具说明从system里挪到一条单独的user消息里效果也不如直接拼在system里稳定。修复工具调用分支用temperature0.1日常对话分支用0.3~0.7不同场景拆成不同的调用路径。这里总结了一条经验想让模型做结构化输出就得牺牲掉一部分创造力。没有参数能两全只有根据场景做取舍。下面把三个坑汇总成一张表方便你对照问题现象根因修复流式解析错位抛JSON解析异常没处理[DONE]和多行间隔先判断[DONE]再parse上下文膨胀答非所问、请求变慢messages无限增长token超量截断引导模型聚焦温度过高工具调用不稳定随机性压过了格式约束工具分支降低temperature7. 第一天的成果和JchatMind接下来的路一天写下来JchatMind拥有的其实就三样东西一个能通过HTTP协议跟大模型通信的Python函数一个能维持多轮对话的messages循环一个能触发本地工具并回喂结果的极简工具调用骨架。代码量不多但每一步都是亲手敲出来的每一行都知道它在干什么。我特别想说的是做完这些之后回头看那些框架里顺手一点就有的功能在这里都变成了一个个需要自己决策的细节。比如system prompt怎么设计才能既约束格式又不压抑个性上下文保留多少轮才能兼顾记忆和成本工具调用失败是重新让模型选一次还是直接告诉用户做不到温度、max_tokens这些参数在不同分支应该用什么值这些问题没有标准答案只能靠实践和积累。接下来我会继续往下走。脑子里已经列了几个方向一是把上下文从截断升级成按token数裁剪加摘要压缩二是把工具调用升级成可靠的function calling协议让模型原生支持结构化调用三是给JchatMind加一个简易的记忆持久化让它在重启后还能记住用户偏好。每一步都会在同一套代码上迭代继续以从底层做起的方式展开这个系列。第一天的JchatMind还很简陋但地基已经切切实实落地了。下一篇文章我会开始处理上下文管理的升级——这也是智能体从demo走向能真正连续工作的关键一步。如果你也在做类似的事欢迎参考这套思路从最原始的那一次HTTP调用开始亲手给你的智能体打好底座。