LLM API 实战基础

专栏:Agent 工程 · 第 3 / 18 篇
AgentLLM APIFunction Calling

构建 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 专栏的主角)的核心工作就是翻译这些差异:

概念OpenAIAnthropicGemini
工具声明tools[].functiontools[](name/description/input_schema 平铺)tools[].functionDeclarations
模型发起调用message.tool_calls[]content[].type="tool_use"functionCall
结果回填role:"tool" + tool_call_idrole:"user" + tool_resultrole:"user" + functionResponse
系统提示词messages[0].role="system"顶层 system 字段systemInstruction
取 key 位置Authorization: Bearerx-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 串)。

三个必须刻进肌肉的细节:

  1. 模型返回的是意图不是结果——arguments 是要你执行的参数(JSON 字符串),执行方是你的代码;
  2. tool_call_id 配对——工具结果必须用这个 id 关联回它的调用,多工具并发时靠它对号;
  3. 历史是 append-only——助手消息、工具结果都按序追加,没有”改写”。

常见踩坑

  1. tool 消息没有紧跟在带 tool_calls 的 assistant 消息之后——顺序错误会直接 400;
  2. 忘了把 assistant 消息(含 tool_calls)追加进历史就回填结果——服务端找不到 call 的归属;
  3. arguments 当对象直接用——它是 JSON 字符串,忘了 json.loads 会得到一个”奇怪的字符串参数”;
  4. 工具描述写了但模型从不调用——90% 是描述没说清触发场景(第 5 篇专治);
  5. 流式与非流式混用对不上——SSE 的分片要自己组装成完整块,调试期先用非流式。

随堂练习(带验收标准)

  1. 跑通往返示例,打印两个方向的完整请求/响应体。验收:能指着 JSON 说出每个字段的角色;
  2. 故意把 tool_call_id 改错再发请求。验收:能解释服务端返回的错误信息;
  3. 给示例加第二个工具 list_dir,验收:模型能根据问题自主在两个工具间选择。

一次 Function Calling 的完整往返

图表(llm-api-fundamentals.md)

两个解剖对象的模型接入层

两个解剖对象的模型接入层

同一个问题,两家给出了教科书级的不同答案:

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。

← 返回文章列表