从零手写智能体:第一天裸调用大模型API
今天开始我要把 JchatMind 这个智能体项目从零搭起来。系列文章的第一天不打算上任何现成的框架也不碰那些炫酷的 Agent 编排工具就做一件最朴素的事用最原始的方式直接调用大模型 API让它开口说话。别觉得简单这一步恰恰是后面所有功能的地基。JchatMind 是我计划中一个偏“私有化”的智能体项目最终形态会包含多轮对话、工具调用、短期记忆和长期记忆但第一天我只关心一件事怎么用代码把一个请求发出去并且拿到模型回复里那段干干净净的正文文本。这个系列适合想从零手写智能体的开发者也适合那些已经在用各种框架、却始终没搞明白底层 JSON 长什么样的朋友——相信我看懂原始调用之后再看框架文档会顺畅很多。写这篇的时候我特意不用任何官方 SDK而是直接用 HTTP 请求库去调 chat/completions 接口。因为 SDK 虽然省事但它会把很多关键细节藏起来。你拿不到一次原始请求的样子就不明白为什么后面加个工具调用会报错也不理解上下文为什么“越聊越贵”。从最原始的地方开始很多问题会变得透明。1. 先想明白JchatMind 要做什么以及为什么第一步必须是“裸调用”1.1 JchatMind 不是聊天机器人而是一个会自己“接任务”的智能体先给项目定个位。JchatMind 不是一个简单的聊天机器人它的核心目标有三个能理解用户意图能自己决定调用哪些工具能在一段连续对话里保持上下文一致。听起来很唬人但拆到最底层所有能力都绕不开一件事——向大模型发送一个结构正确的请求并解析它的响应。团队里之前有位同事用框架搭过类似的 demo功能看起来都跑通了但一旦要深入定制比如修改人设注入方式、调整工具选择的策略他就得去扒框架的源码改起来非常痛苦。原因很简单框架替你做了太多决定你和模型之间的“协议”是黑盒。所以我决定换条路先裸调把协议层彻底吃透再决定要不要引入封装。这也是 JchatMind 第一天只做“最原始的大模型调用”的原因。1.2 框架能晚点用就晚点用先把手伸进 HTTP 层很多新手会觉得用框架不是更快吗是更快但那是“跑通 demo”的快不是“理解系统”的快。打个比方学做菜你得先学会开火、切菜、放油而不是第一步就上分子料理设备。框架相当于分子料理设备功能强大但会掩盖基本功的缺失。裸调的核心价值有两个。第一你能看到一次请求的完整结构URL、鉴权头、请求体里的 model 和 messages、响应里的 choices 和 usage。第二你能控制请求的每一个细节。JchatMind 后面要做的工具调用和多轮记忆本质上都是往 messages 和响应结构里塞更多字段。HTTP 层弄明白了这些扩展都只是“加字段”而已。所以第一天我不图快只图把链路走通、走明白。能徒手发出一个合法的请求并拿到回复比用框架跑出一百轮对话更有价值。1.3 第一天目标定得很小拿到一段真实的模型回复给自己定一个可验收的目标避免漫无目的地折腾写一个 Python 脚本向大模型接口发送“你好请简单介绍一下自己”成功拿到回复并打印出来。就这么简单。但要做到“成功拿到”这四个字中间涉及模型选型、密钥配置、请求格式、响应解析每一步都有坑。这一天的产出不只是一个能跑的脚本还包括一份自己的理解请求体里的 messages 是怎么组织的返回的 JSON 里哪个字段是真正的内容token 是怎么计数的。这些东西后面每天都会用到今天一次性搞清楚后面就可以专心做功能了。2. 动手前的准备工作模型选择、密钥管理和 BaseURL 的坑2.1 怎么挑一个适合当“地基”的大模型 API市面上能直接通过 HTTP 调用的大模型服务很多这里我不具体点名但可以分享我挑选时的几个硬标准JchatMind 第一天用的就是满足这些标准的一套服务。第一个标准是接口兼容性。现在主流服务几乎都兼容 chat/completions 这种格式也就是说请求体里就是 model、messages、temperature 这几个字段响应是 choices 加 usage。选这种格式的好处是后期想换服务商代码改动量很小。第二个标准是上下文长度。第一天不重要但智能体后面要做多轮记忆上下文太短很快会被聊天记录撑爆。建议至少选 32K 以上的模型。第三个标准是计费透明和免费额度。第一天的调试会产生大量无效请求免费额度够用会舒服很多。还要关注输出单价和输入单价的差异智能体场景中历史记录反复重发输入 token 会占大头这块成本一定要提前算。2.2 密钥、环境变量与配置文件从一开始就别偷懒模型选好后的第一件事是创建 API Key。每个服务商的管理控制台里都会有创建密钥的入口创建后密钥通常只显示一次一定要立刻保存。跑通调试后再顺手把密钥从代码里挪到环境变量。我习惯在项目根目录放一个.env文件配合 python-dotenv 加载。这个文件不进版本库。项目里的配置文件则统一管理模型名和接口地址这样以后切换服务商只需要改配置不用动代码。先安装两个依赖pip install requests python-dotenv然后创建.env文件内容大概是这样的JCHAT_LLM_API_KEYsk-your-key-here JCHAT_LLM_BASE_URLhttps://api.example-llm.com/v1 JCHAT_LLM_MODELjchat-chat-v1注意这个环境变量命名我统一加了JCHAT_LLM_前缀避免和机器上其他项目的配置冲突。这个习惯做久了就知道多重要尤其是同时维护多个项目的时候。2.3 BaseURL第一天最容易踩的隐形坑如果说第一天只能记住一个坑那一定是 BaseURL。很多服务商除了提供官方默认地址之外还会允许你配置自定义网关或兼容接入点但这个地址并不总是默认值。如果你用的是兼容接口文档里给出的 BaseURL 可能长这样https://某个域名/v1。它必须以/v1结尾因为最终的请求路径是BaseURL /chat/completions。我第一次写的时候就是把 BaseURL 末尾的/v1漏了结果请求打到了不带版本号的路径上接口直接返回 404。查了半小时最后才发现是少了一个路径段。所以配置 BaseURL 时一定要先确认末尾路径并且搞清楚最终的完整 URL 是什么。在代码里我建议不要硬编码 URL而是从环境变量读取并设置一个合理的默认值。这样换环境的时候只改.env不用改代码。3. 最原始的调用用 requests 手写第一个 chat/completions 请求3.1 完整代码不到 30 行把模型“叫醒”下面这一段就是 JchatMind 的第一版核心代码。没有 SDK就是最朴素的 HTTP POST。import os import requests from dotenv import load_dotenv load_dotenv() API_KEY os.getenv(JCHAT_LLM_API_KEY) BASE_URL os.getenv(JCHAT_LLM_BASE_URL) MODEL os.getenv(JCHAT_LLM_MODEL) url f{BASE_URL}/chat/completions headers { Authorization: fBearer {API_KEY}, Content-Type: application/json, } payload { model: MODEL, messages: [ {role: user, content: 你好请用一句话介绍你自己} ], temperature: 0.7, } resp requests.post(url, headersheaders, jsonpayload, timeout30) resp.raise_for_status() data resp.json() text data[choices][0][message][content] print(text)跑完如果看到一行模型回复恭喜第一天最关键的链路已经通了。这段代码看起来不起眼但它是 JchatMind 后面所有模块共同依赖的“心脏”。我建议你先跑通再往下看逐行拆解。3.2 逐行拆解HTTP 层到底发生了什么一行一行看。requests.post是向接口发 POST 请求因为创建对话是一种“提交数据”的操作不是单纯的“查询”所以用 POST 而不是 GET请求体放在 body 里而不是 URL 上。headers里的Authorization是鉴权字段格式是Bearer加一个空格加密钥。这是目前大多数服务通用的鉴权方式。Content-Type: application/json告诉服务端你发送的是 JSON。payload是整个请求的核心。model指定要调用哪个模型不同模型的能力和价格差别很大。messages是对话消息数组这里只有一条 user 消息意思就是“当前对话中用户说了这句话”。temperature控制回复的随机性这个后面专门讲。timeout30很关键不设的话请求可能一直挂着脚本会卡死在原地。设了超时之后至少能收到一个异常方便定位问题。resp.raise_for_status()这句话是检查 HTTP 状态码如果返回 4xx 或 5xx 就抛异常等于帮我们拦一道错误。最后两行是把响应体解析成 JSON再按层级取出choices[0].message.content。大模型真正的回复文本就藏在这个字段里。3.3 返回的 JSON 里藏着什么choices、finish_reason、usage跑通之后别急着写下一个功能先把原始响应打出来看看。把上面的代码最后一行改成import json print(json.dumps(data, ensure_asciiFalse, indent2))你会看到类似这样的结构{ id: chatcmpl-xxxx, object: chat.completion, created: 1730000000, model: jchat-chat-v1, choices: [ { index: 0, message: { role: assistant, content: 你好我是 JchatMind一个基于大模型的智能助手。 }, finish_reason: stop } ], usage: { prompt_tokens: 12, completion_tokens: 22, total_tokens: 34 } }字段含义大概如下字段作用choices候选回复列表一般是一个多个时取 index0choices[0].message.content模型回复的正文choices[0].finish_reason结束原因stop 表示正常结束length 表示超长被截断usage.prompt_tokens输入消耗的 token 数usage.completion_tokens输出消耗的 token 数usage.total_tokens总消耗我第一次看到finish_reason的时候没当回事后来才发现它特别重要。如果回复被 max_tokens 截断finish_reason会变成length这时候你取到的 content 只是半截话。所以后续 JchatMind 在判断回复完整性时必须要看这个字段。3.4 从“抽卡式回答”到稳定输出temperature、max_tokens、top_p如果你连续跑几次上面的代码会发现同样的 prompt回复每次都不一样。这是因为模型本身就是概率输出而 temperature 控制了随机程度。简单理解temperature 越高模型越“放飞”0.7 左右适合闲聊和创意写作temperature 越低输出越保守0.2 以下适合需要确定性的场景比如稳定返回 JSON。后面 JchatMind 做工具调用时我大概率会把 temperature 调到 0 或 0.1避免模型“自由发挥”导致参数格式变来变去。max_tokens控制回复的最大长度单位是 token 而不是字。中文场景下一个 token 大约对应一到两个汉字但你别拿字符数去估一定要用服务商工具或 usage 字段来实测。设 max_tokens 更大的意义是保护成本如果有一次异常输入导致模型开始“滔滔不绝”它能起到一个上限的作用。还有一个top_p它做的是另一种采样策略一般是保留概率最高的一部分候选词再抽样。官方建议不要同时调 temperature 和 top_p否则可能互相干扰。我的习惯是只调 temperaturetop_p 保持在默认值不动。4. 从一次调用到多轮对话让 JchatMind 开始有“记忆”4.1 大模型没有记忆所谓上下文就是“把聊天记录再发一遍”现在能拿到单次回复了但智能体不能只回答一句话它得能聊下去。很多人以为模型自带记忆其实大模型本身是完全无状态的。它“记得”前面的对话只是因为你在每次请求时把所有历史对话都原封不动地又发了一遍。举个例子你第一轮说“我叫小明”模型回答“你好小明”。第二轮你想让它记住这一点就必须在第二轮的请求里带上第一轮的对话内容包括你自己的话和它自己的回答。所以 messages 数组会越来越长。在 messages 里角色有三种常用取值system设置人设和全局规则user代表用户输入assistant代表模型历史输出。系统提示词不回复用户只作为幕后指令。4.2 手工维护 messages 的最小实现系统人设 历史轮次实现一个简单的命令行版多轮对话只需要维护一个 Python 列表messages [ {role: system, content: 你是 JchatMind一个简洁、可靠、诚实的智能助手。} ] while True: user_input input(你 ) if user_input.strip().lower() in (exit, quit): break messages.append({role: user, content: user_input}) payload { model: MODEL, messages: messages, temperature: 0.3, } resp requests.post(url, headersheaders, jsonpayload, timeout30) resp.raise_for_status() data resp.json() reply data[choices][0][message][content] messages.append({role: assistant, content: reply}) print(fJchatMind {reply})核心逻辑就两步用户发言追加到 messages模型回复也追加到 messages。每次请求都带全量列表。你可以试一下这个对话序列“我叫小明”“我叫什么名字”“我第一句话说了什么”——如果消息历史维护正确模型会答对。这里有个细节值得注意assistant消息必须是你“实际收到”的回复原始文本不要自己拼接或者修改。如果历史被篡改模型后面的逻辑会变得混乱。4.3 第一道坎上下文膨胀轮数一多 token 就爆多轮跑起来之后很快会遇到新问题聊几十轮之后messages 越来越长每次请求的输入 token 越来越大。这意味着两件事延迟变高、费用变高。而且一旦总 token 数超过模型上下文上限请求会直接报错这个错误在第一天多轮测试时几乎一定会遇到。最简单的临时方案是只保留最近 N 轮对话把更早的历史裁掉def trim_messages(messages, max_rounds10): system_msg [messages[0]] history messages[1:] keep history[-max_rounds * 2:] return system_msg keep这个方案很粗暴但至少能在几十轮内维持稳定。它的代价是“突然失忆”模型会忘记很早就提到过的信息。JchatMind 后续会做摘要压缩和历史向量化存储但那是后面几天的内容。今天先把问题暴露出来知道它存在就已经赢了。5. 前三天一定会踩的坑异常处理、重试策略与排查技巧5.1 报错特征速查表看到什么提示去查哪里第一天跑通之后紧接着就是各种花式报错。我把最近踩过的坑整理成一张速查表你遇到类似情况可以直接对号入座。报错特征最可能的原因401 / 403密钥错误、未加 Authorization 头、密钥复制不完整404BaseURL 少了 /v1或 /chat/completions 路径拼错400 invalid messagesmessages 结构不对role 写成了不存在的值429 Too Many Requests触发限流或账号欠费/额度耗尽5xx服务端问题需要延迟重试timeout网络状况或模型推理较慢设大 timeout 并重试finish_reason length回复被截断调大 max_tokens 或精简提示词尤其要提醒的是 429。很多时候不是因为你是高并发而是免费额度的每分钟请求数限制很严格。这种时候盲目加大并发只会捅更大的篓子应该退避等待。5.2 网络超时与限流指数退避重试的写法网络请求没有一次成功的说法尤其在智能体场景里一次业务操作可能要连续调用好几次模型。为了让 JchatMind 更健壮我封装了一层简单的重试逻辑import time def chat_completion(payload, max_retries3): for attempt in range(max_retries): try: resp requests.post(url, headersheaders, jsonpayload, timeout60) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException: if attempt max_retries - 1: raise time.sleep(2 ** attempt)这里用的是指数退避第一次失败等 1 秒第二次等 2 秒第三次等 4 秒。原因是 429 和 5xx 往往意味着服务端过载立刻重试大概率还是失败等一会儿反而可能触发配额刷新。当然4xx 这类请求错误重试也没意义最好单独判断下状态码再决定是否重试。还要注意timeout 不要设太短。大模型接口的响应时间和普通接口不一样复杂请求跑二三十秒很正常。我见过太多人把 timeout 设成 5 秒然后疯狂误报“接口挂了”其实只是模型还在思考。5.3 调试过程的三个独家建议第一先用最小请求验证链路。第一次测试时把 messages 只留一句话max_tokens 设成 16能快速返回就行。链路通了再逐步加大复杂度这样可以把“请求格式问题”和“业务逻辑问题”分开定位。第二把原始响应完整打出来看。调试阶段务必用json.dumps(data, ensure_asciiFalse, indent2)把完整 JSON 打印出来不要只打印 content。因为 usage、finish_reason 这些字段才是判断问题根源的关键。第三给每次调用加日志。刚开始可能觉得无所谓但调试多轮对话时日志能帮你回放请求历史看清楚是哪一轮消息把 token 撑爆的。JchatMind 后面接日志系统是必然的第一天先用 print 也行重点是养成习惯。提醒任何情况下不要在日志里打印完整的 API Key。密钥一旦泄露别人就能拿你的额度疯狂调用账单会非常刺激。我实际中遇到过泄露后密钥被刷了上万次的情况处理起来相当麻烦。6. 第一天的自测清单以及我对“原始阶段”的一点体会6.1 自测任务能过关才算真正跑通第一天的目标不是“代码能跑”而是“链路已经吃透”。建议你做这样几个自测成功拿到一条非空回复并打印出原始 JSON。设置 temperature 为 0连续跑三次同一个请求确认输出完全一致。让模型输出一段 JSON比如“返回一个包含姓名和年龄的JSON”你再用json.loads直接解析它。跑通三轮以上的对话验证“我叫小明”这种信息能被正确记住。连续对话 20 轮观察 token 数量变化确认没有触发上下文上限。如果第 3 项失败了多半是 temperature 太高或者提示词不够具体。这也是为后面工具调用打基础因为工具调用本质上就是让模型返回一段结构化的 JSON 指令。6.2 为什么我会让 JchatMind 从最原始的地方开始最后说一点自己的真实感受。以前我也图省事上来就套各种框架结果遇到问题就像在通缉令里找线索完全无从下手。这次从裸调开始才真正把大模型调用当成一个“普通 HTTP 接口”来理解。框架能帮你加速但无法帮你建立基础认知。第一天跑通之后JchatMind 的地基算是打好了。后面要做的事还很多让模型学会调用工具、把短期记忆升级成持久化记忆、加上一点自我反思机制。但不管多复杂的设计底层还是今天这个结构构造 messages、发请求、拿 choices、看 usage。把这个过程内化成肌肉记忆后面遇到什么高级功能都不会慌。我个人的建议是第一天的项目就保持这个最小规模不要急着加功能。把这份原始调用的代码留在仓库里标一个v0.1的标签后面哪天被框架绕晕了随时翻回来看一眼你会感谢今天这个决定。