☰
从零手写智能体:第一天裸调用大模型API
2026/10/20 13:56:01 网站建设 项目流程

今天开始,我要把 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_KEY=sk-your-key-here JCHAT_LLM_BASE_URL=https://api.example-llm.com/v1 JCHAT_LLM_MODEL=jchat-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": f"Bearer {API_KEY}", "Content-Type": "application/json", } payload = { "model": MODEL, "messages": [ {"role": "user", "content": "你好,请用一句话介绍你自己"} ], "temperature": 0.7, } resp = requests.post(url, headers=headers, json=payload, timeout=30) 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控制回复的随机性,这个后面专门讲。

timeout=30很关键,不设的话请求可能一直挂着,脚本会卡死在原地。设了超时之后,至少能收到一个异常,方便定位问题。

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_ascii=False, indent=2))

你会看到类似这样的结构:

{ "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候选回复列表,一般是一个,多个时取 index=0
choices[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,否则可能互相干扰。我的习惯是只调 temperature,top_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, headers=headers, json=payload, timeout=30) resp.raise_for_status() data = resp.json() reply = data["choices"][0]["message"]["content"] messages.append({"role": "assistant", "content": reply}) print(f"JchatMind> {reply}")

核心逻辑就两步:用户发言追加到 messages,模型回复也追加到 messages。每次请求都带全量列表。你可以试一下这个对话序列:“我叫小明”“我叫什么名字?”“我第一句话说了什么?”——如果消息历史维护正确,模型会答对。

这里有个细节值得注意:assistant消息必须是你“实际收到”的回复原始文本,不要自己拼接或者修改。如果历史被篡改,模型后面的逻辑会变得混乱。

4.3 第一道坎:上下文膨胀,轮数一多 token 就爆

多轮跑起来之后,很快会遇到新问题:聊几十轮之后,messages 越来越长,每次请求的输入 token 越来越大。这意味着两件事:延迟变高、费用变高。而且一旦总 token 数超过模型上下文上限,请求会直接报错,这个错误在第一天多轮测试时几乎一定会遇到。

最简单的临时方案是只保留最近 N 轮对话,把更早的历史裁掉:

def trim_messages(messages, max_rounds=10): 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_retries=3): for attempt in range(max_retries): try: resp = requests.post(url, headers=headers, json=payload, timeout=60) 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_ascii=False, indent=2)把完整 JSON 打印出来,不要只打印 content。因为 usage、finish_reason 这些字段才是判断问题根源的关键。

第三,给每次调用加日志。刚开始可能觉得无所谓,但调试多轮对话时,日志能帮你回放请求历史,看清楚是哪一轮消息把 token 撑爆的。JchatMind 后面接日志系统是必然的,第一天先用 print 也行,重点是养成习惯。

提醒:任何情况下不要在日志里打印完整的 API Key。密钥一旦泄露,别人就能拿你的额度疯狂调用,账单会非常刺激。我实际中遇到过泄露后密钥被刷了上万次的情况,处理起来相当麻烦。

6. 第一天的自测清单,以及我对“原始阶段”的一点体会

6.1 自测任务:能过关才算真正跑通

第一天的目标不是“代码能跑”,而是“链路已经吃透”。建议你做这样几个自测:

  1. 成功拿到一条非空回复,并打印出原始 JSON。
  2. 设置 temperature 为 0,连续跑三次同一个请求,确认输出完全一致。
  3. 让模型输出一段 JSON(比如“返回一个包含姓名和年龄的JSON”),你再用json.loads直接解析它。
  4. 跑通三轮以上的对话,验证“我叫小明”这种信息能被正确记住。
  5. 连续对话 20 轮,观察 token 数量变化,确认没有触发上下文上限。

如果第 3 项失败了,多半是 temperature 太高或者提示词不够具体。这也是为后面工具调用打基础,因为工具调用本质上就是让模型返回一段结构化的 JSON 指令。

6.2 为什么我会让 JchatMind 从最原始的地方开始

最后说一点自己的真实感受。以前我也图省事,上来就套各种框架,结果遇到问题就像在通缉令里找线索,完全无从下手。这次从裸调开始,才真正把大模型调用当成一个“普通 HTTP 接口”来理解。框架能帮你加速,但无法帮你建立基础认知。

第一天跑通之后,JchatMind 的地基算是打好了。后面要做的事还很多:让模型学会调用工具、把短期记忆升级成持久化记忆、加上一点自我反思机制。但不管多复杂的设计,底层还是今天这个结构:构造 messages、发请求、拿 choices、看 usage。把这个过程内化成肌肉记忆,后面遇到什么高级功能都不会慌。

我个人的建议是,第一天的项目就保持这个最小规模,不要急着加功能。把这份原始调用的代码留在仓库里,标一个v0.1的标签,后面哪天被框架绕晕了,随时翻回来看一眼,你会感谢今天这个决定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询