LLM API 实战基础
专栏:Agent 工程 · 第 3 / 18 篇构建 agent 不需要先学 Transformer 数学,但必须对 API 这一层形成肌肉记忆。这篇过一遍必备概念,每个都带上两个解剖对象的真实做法。
:::info 学习目标 完成本篇后你能够:不看教程裸写一次带工具调用的 API 往返;说清 token 与上下文窗口对 agent 成本结构的影响;解释「模型返回的是调用意图而非结果」的完整含义。 前置:会一门语言的 HTTP 请求。预计时长:45 分钟。 :::
:::note 本章术语速查(新手建议先读)
- Token(词元):模型处理文本的最小单位。一个英文单词约等于 1.3 个 token,一个汉字约 1~1.5 个。计费和上下文长度都按它算。
- 上下文窗口(Context Window):模型一次能”看到”的最大 token 数,超过就装不下。对话历史、系统提示词、工具说明全占这个空间。
- Temperature(温度):控制模型回答的随机性。越低越稳定(适合 agent),越高越有创意。
- 流式输出(SSE):回复像打字机一样逐段到达,而不是等全部生成完。SSE 是实现它的网络技术。
- Wire 格式:发送给 API 的请求长什么样(字段名、结构)。不同厂商的字段名不一样。
- 适配器(Adapter):一层”翻译”代码——把统一的内部格式转换成某家厂商的 wire 格式。 :::
三大 provider 的 wire 格式对照
同一概念在不同 provider 的请求里字段名不同——网关类产品(如 new-api 专栏的主角)的核心工作就是翻译这些差异:
| 概念 | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| 工具声明 | tools[].function | tools[](name/description/input_schema 平铺) | tools[].functionDeclarations |
| 模型发起调用 | message.tool_calls[] | content[].type="tool_use" | functionCall |
| 结果回填 | role:"tool" + tool_call_id | role:"user" + tool_result 块 | role:"user" + functionResponse |
| 系统提示词 | messages[0].role="system" | 顶层 system 字段 | systemInstruction |
| 取 key 位置 | Authorization: Bearer | x-api-key | ?key= / x-goog-api-key |
两个解剖对象对这张表的回应截然不同:Codex 砍掉多样性——只支持 OpenAI Responses API(chat completions 的 wire 支持已从代码移除,配置即报错),换取服务端压缩等深度能力;dsh 拥抱多样性——ctx.llm 适配器 seam + 统一的 StreamChunk 词汇表(七种分片),各提供方实现 LlmAdapter.stream() 接入。自建 agent 的建议:先直连一家把循环跑通,抽象适配器接口的时机是”真的要接第二家”的那天。
token 与上下文窗口:agent 的经济学
- token:模型的输入输出计量单位,英文约 0.75 词/元(汉字约 1~1.5 字/元)。计费、限速、上下文窗口全按 token 数。
- 上下文窗口:单次请求能容纳的最大 token 数。agent 的整个对话历史 + 系统提示词 + 全部工具 schema 都在里面。
为什么这对你最重要:agent 的每次工具调用都会让历史变长,而每次请求都重发全部历史。算一笔具体账:一个 50 步的任务,假设每步新增(系统提示词 2000 + 历史增量 800)token——
第 1 步请求 ≈ 2,800 token
第 10 步请求 ≈ 10,000 token
第 50 步请求 ≈ 42,000 token
全程累计发送 ≈ 1,120,000 token ← 不是 42,000,是它们的和
这就是为什么第 6 篇(上下文经济学)和第 7 篇(压缩)是专栏核心,也是为什么两个解剖对象都内建了上下文管理:
- Codex:每次 turn 都记录
TokenUsageRecord进会话日志,模型甚至有一个内建工具get_context_remaining让它自己查看剩余预算; - dsh:session 事件里嵌入
usage,压缩(compaction)作为 turn 循环内的一等公民。
采样参数:你需要动用的只有三个
| 参数 | 作用 | agent 里的建议值 |
|---|---|---|
temperature | 随机性。0 ≈ 确定性复现 | 工具调用场景给低值(0~0.3) |
max_tokens | 单次回复上限 | 留足工具调用与思考空间 |
stop/工具结束 | 终止信号 | agent 用 finish_reason 而非文本 stop |
确定性对 agent 很重要:同一输入应产生同一工具序列,否则评测(第 13 篇)无法回归。
流式输出:不只是体验
流式(SSE)让回复边生成边到达。对 agent 它还有工程意义:工具调用块在流中一确定就能开跑,不必等整包。Codex 把这点做到了极致——try_run_sampling_request 里流读取与工具执行并发(FuturesOrdered):模型还在吐后面的 token,前面的工具调用已经开始执行。dsh 同构:流 chunk 实时发 agent/assistant-stream 事件,settlement 时才落日志。
Function Calling:agent 的全部地基
这是本篇最重要的部分。Function calling = 你声明函数 schema,模型返回结构化的调用意图,你执行后把结果回填。完整的一个来回:
import json, urllib.request
tools = [{
"type": "function",
"function": {
"name": "read_file",
"description": "读取磁盘上的文本文件。用于查看文件内容。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "绝对路径"},
"limit": {"type": "integer", "description": "最多读取行数"},
},
"required": ["path"],
},
},
}]
messages = [{"role": "user", "content": "看看 package.json 里有哪些依赖"}]
# 第一次请求:带工具清单
req = urllib.request.Request(
"https://api.deepseek.com/chat/completions",
data=json.dumps({"model": "deepseek-chat", "messages": messages, "tools": tools}).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer <KEY>"},
)
reply = json.load(urllib.request.urlopen(req))["choices"][0]["message"]
messages.append(reply) # 助手消息(含 tool_calls)入历史
# 模型返回的不是执行结果,而是"调用意图"
if reply.get("tool_calls"):
for call in reply["tool_calls"]:
args = json.loads(call["function"]["arguments"])
result = open(args["path"]).read()[:2000] # 你来执行
messages.append({
"role": "tool",
"tool_call_id": call["id"], # 用 call_id 配对
"content": result,
})
# 第二次请求:模型基于工具结果作答(第 4 篇把它变成循环)
检查点:跑通后打印 reply 看一眼,应该看到类似这样的结构——
{"role": "assistant", "content": null,
"tool_calls": [{"id": "call_0_abc123", "type": "function",
"function": {"name": "read_file", "arguments": "{\"path\": \"package.json\"}"}}]}
注意 arguments 是字符串(模型原样产出),要自己 json.loads——这正是两个生产系统都”原样存日志、不预先解析”的原因(dsh 的 tool/call 事件注释明确写了 arguments 为未解析 JSON 串)。
三个必须刻进肌肉的细节:
- 模型返回的是意图不是结果——
arguments是要你执行的参数(JSON 字符串),执行方是你的代码; tool_call_id配对——工具结果必须用这个 id 关联回它的调用,多工具并发时靠它对号;- 历史是 append-only——助手消息、工具结果都按序追加,没有”改写”。
常见踩坑
tool消息没有紧跟在带tool_calls的 assistant 消息之后——顺序错误会直接 400;- 忘了把 assistant 消息(含 tool_calls)追加进历史就回填结果——服务端找不到 call 的归属;
- 把
arguments当对象直接用——它是 JSON 字符串,忘了json.loads会得到一个”奇怪的字符串参数”; - 工具描述写了但模型从不调用——90% 是描述没说清触发场景(第 5 篇专治);
- 流式与非流式混用对不上——SSE 的分片要自己组装成完整块,调试期先用非流式。
随堂练习(带验收标准)
- 跑通往返示例,打印两个方向的完整请求/响应体。验收:能指着 JSON 说出每个字段的角色;
- 故意把
tool_call_id改错再发请求。验收:能解释服务端返回的错误信息; - 给示例加第二个工具
list_dir,验收:模型能根据问题自主在两个工具间选择。
一次 Function Calling 的完整往返
两个解剖对象的模型接入层
两个解剖对象的模型接入层
同一个问题,两家给出了教科书级的不同答案:
Codex:单提供方,深度耦合。 只支持 OpenAI Responses API——model-provider-info 里 chat completions 的 wire 支持已被移除(配置了直接报错)。深度耦合换来独家能力:服务端上下文压缩、加密的函数参数(encrypted_function_args)、远程模型目录(默认模型从 catalog 推导,而非硬编码)。
dsh:适配器 seam,多提供方。 ctx.llm 服务统一了消息/流式词汇表(StreamChunk 七种分片:block-start / text-delta / reasoning-delta / tool-call-delta / block-end / usage / finish),各提供方实现 LlmAdapter.stream() 接入。deepseek 适配器默认端点 https://api.deepseek.com/chat/completions,密钥经凭据 seam 按请求解析(轮换免重启)。
两种取舍没有对错:单提供方能吃到平台最深的能力,适配器 seam 换来供应商自由。自建 agent 的建议:先直连一家 API 把循环跑通(下一篇),抽象出适配器接口的时机是”真的要接第二家”的那天。
本篇产出
跑通上面的裸 API 示例:不带循环,完成一次「模型请求 → 返回 tool_calls → 你执行 → 回填 → 模型作答」的完整往返。下一篇把这个往返装进 while 循环——那一步之后,你就有了自己的第一个 agent。