Chapter 01
核心原语:七个词汇,所有 API 共用
起点建立了一句话本质——模型是个无状态的 next-token 函数,其余都是脚手架。这一章给出搭脚手架用的七个原语,每个用同一个客服查订单的场景走查一遍,让你在具体的请求 / 响应上建立直觉。
本章你将建立的 schema
- 请求是什么:messages + 角色 + 无状态——"会话"是你在客户端维护的错觉
- token 是计量单位:上下文、输出上限、账单都以它计
- 四个产出侧原语:流式、工具调用闭环、结构化输出、采样参数
- 两个资源侧原语:上下文窗口与 prompt caching;外加 reasoning 模型这个新成员
一个电商客服助手。用户问"我上周买的耳机到哪了?"。助手需要:读懂对话 → 调用 get_order_status 工具查物流 → 用结构化的 {status, eta, carrier} 回给前端展示。七个原语会在这一个场景里轮番登场——后面的原语会复用前面的设定。
1请求 = messages + 角色,而且无状态
一次调用,就是把"角色标注过的整段对话"作为输入,换回模型续写的下一段 assistant 内容。
没有角色标注,模型分不清哪句是给它的指令(system)、哪句是用户说的(user)、哪句是它自己上轮说的(assistant)。没认清"无状态",你会以为服务器替你记着对话,结果第二轮发现上下文全丢了。
底层机制(比文档深一层):角色不是被服务端强制校验的字段,而是模型在训练(RLHF / instruct 微调)时学会服从的特殊 token——比如 ChatML 把消息渲染成 <|im_start|>system … <|im_end|> 这样的定界符。正因为如此,服务器本身是无状态的:每次调用你都要把完整历史重发一遍;所谓"会话"完全是客户端维护的,模型那一端什么都不记得。这一条会一路解释后面的成本、缓存、注入问题。
像给一个每次读完就失忆的专家递一沓便签:他读完全部便签、写一张新的还给你,随即忘光。边界:他不是真失忆——是这套 API 设计成每轮无状态。OpenAI 的 Responses API 现在可选 store:true 让服务端替你存历史,但底层模型那一步推理仍然是无状态的,存的只是便签本身。
走查:同一个 system + user,三家怎么表达
// OpenAI(Chat Completions 形态)—— system 作为一条 message
{ "model": "gpt-5.5",
"messages": [
{"role": "system", "content": "你是电商客服,只回答订单相关问题。"},
{"role": "user", "content": "我上周买的耳机到哪了?"}
] }
// Anthropic —— system 是顶层参数,不是 message;max_tokens 必填
{ "model": "claude-opus-4-8",
"max_tokens": 1024,
"system": "你是电商客服,只回答订单相关问题。",
"messages": [
{"role": "user", "content": "我上周买的耳机到哪了?"}
] }
// Google Gemini —— 无 system 角色,用 systemInstruction;assistant 叫 "model"
{ "systemInstruction": {"parts": [{"text": "你是电商客服,只回答订单相关问题。"}]},
"contents": [
{"role": "user", "parts": [{"text": "我上周买的耳机到哪了?"}]}
] }
共同点三家都是一个有序的、角色标注的消息列表。
差异 1system 放哪:OpenAI 当 message、Claude 顶层 system、Gemini 顶层 systemInstruction——没有一家用 system 这个"角色"。
差异 2助手那一方:OpenAI / Claude 叫 assistant,Gemini 叫 model。
第二轮,如果你只发新的 user2、不带前面三条,模型会怎样?
展开答案(先停 10 秒)
模型完全不知道第一轮发生过什么——它只看到一句孤立的"那它什么时候到?",无从知道"它"指哪个订单。因为服务器无状态,上下文丢失的责任在你的代码,不在 API。多轮对话 = 你负责把历史一路带上。
2token:一切的计量单位
token 是模型眼里的最小文本单位(约 0.75 个英文词 / 1–2 个汉字),上下文长度、输出上限、账单全部以它计。
按"字符数"或"字数"估算,会让你在上下文溢出和账单上同时翻车。模型不按字符收费,按 token。
底层机制(比文档深一层):tokenizer 用 BPE(byte-pair encoding)把文本切成子词。不同模型用不同 tokenizer——GPT-4o 系用 o200k_base,更早的 GPT 用 cl100k_base,Claude / Gemini 各有自己的。所以"同一段中文有多少 token"在不同模型上答案不同;拿错 tokenizer 去估算就会偏。输入 token(prompt)和输出 token(completion)分开计价,输出通常贵 3–5 倍,因为输出要一个个自回归地算。
// 账单就看这里:输入便宜、输出贵
"usage": {
"prompt_tokens": 38, // 你发进去的 system+user,按输入价
"completion_tokens": 142, // 模型吐出来的,按输出价(约 3-5x)
"total_tokens": 180
}
// 经验值:1 个英文词 ≈ 1.3 token;1 个汉字 ≈ 1-2 token;
// "你好,世界" ≈ 3-5 token(取决于 tokenizer),不是 5
同样一段 1000 字的中文文档,在 GPT-5.5 和 Claude 上消耗的 token 数会一样吗?
展开答案
不会。两家 tokenizer 不同,切分边界不同,token 数会有差异(通常在 10–30% 范围内浮动)。所以跨模型比价时,不能只比"每 token 单价",还要比"同样文本各自切多少 token"。要精确计数,用各家自己的计数工具(OpenAI tiktoken、Anthropic 的 count-tokens 端点)。
3流式(streaming)
不等整段生成完,模型每产出一块就通过 SSE 推给你,首字几百毫秒即到。
一段 200 token 的回答要好几秒。用户盯着空白会以为卡死。流式把"首字时间"(TTFT)从几秒压到几百毫秒——内容总时长没变,但感知延迟骤降。
底层机制(比文档深一层):生成本来就是自回归的——一次前向只产一个 token,所以服务器天然能边生成边发。传输用 Server-Sent Events(Content-Type: text/event-stream):一行行 data: {…} 推过来,每块带一个增量 delta,最后一个 [DONE] 收尾。代价在第 1.1 节的无状态之外又添了一笔:连接要一直挂着;错误只会在 HTTP 200 之后才暴露(藏在某个 chunk 里);想原子地拿到整个对象时,流式反而碍事。
chunk 事件名各家不同OpenAI 旧形态是 chat.completion.chunk、Responses 是语义事件 response.output_text.delta;Claude 是 content_block_delta;Gemini 是 content.delta。形态不同,但都是"一块块 delta"。
流式请求时,HTTP 状态码在什么时刻就定下来了?如果生成到一半服务端出错,你怎么知道?
展开答案
响应头一发出,状态码就锁定为 200——之后再发生的错误只能塞进 body 的某个 chunk 里,状态码不会变。所以流式消费端必须逐块解析、检查末尾的 finish_reason、把 in-band 的 error 事件当失败处理,不能只看 HTTP 状态码。这是第 02 章一个常见陷阱的根。
4工具调用(tool calling)——一个闭环
你把工具的 JSON-Schema 描述递给模型;模型不执行工具,只输出一个"调用请求";你的代码执行,把结果作为新消息喂回;循环直到模型给出文字答复。
模型是个封闭函数——不能联网、查不了数据库、不知道今天日期、算不准大数乘法。工具调用是它唯一的对外触手。客服助手要查物流,就必须靠它。
底层机制(比文档深一层,本章最重要的一条):所谓"调用工具",本质是模型输出了一段结构化的 token(工具名 + JSON 参数)而不是散文。客户端解析这段 token、执行真正的函数、把结果作为 tool 角色的消息追加回历史,再发一次请求。模型自始至终只输出 token,从不自己执行任何东西。这个设计把副作用、密钥、真正的执行全留在你这边——模型连"函数被执行了"都不知道,它只是看到历史里多了一条工具结果。
像一个不能离开房间的专家:他写一张"请去仓库查 #123 订单状态"的纸条递出来,你跑腿查完把结果纸条递回去,他接着分析。他永远不出门,也不知道仓库长什么样。边界:纸条的格式(工具名、参数)是他提议的,有时会写错(幻觉出不存在的工具、参数非法)——所以你执行前要校验。
走查:客服查订单的完整闭环
// ① 请求:带上工具的 schema
{ "model": "claude-opus-4-8", "max_tokens": 1024,
"messages": [{"role":"user","content":"我上周买的耳机(订单 123)到哪了?"}],
"tools": [{
"name": "get_order_status",
"description": "查询订单的物流状态",
"input_schema": {"type":"object",
"properties":{"order_id":{"type":"string"}},
"required":["order_id"]}
}] }
// ② 模型的响应:它没查任何东西,只是 emit 了一个 tool_use 块
{ "stop_reason": "tool_use",
"content": [{"type":"tool_use","id":"tu_01","name":"get_order_status",
"input":{"order_id":"123"}}] }
// ③ 你的代码真正执行 get_order_status("123"),得到 {"status":"shipped",...}
// 把结果作为 tool_result 追加,连同全部历史再发一次:
{ "model": "claude-opus-4-8", "max_tokens": 1024,
"messages": [
{"role":"user","content":"我上周买的耳机(订单 123)到哪了?"},
{"role":"assistant","content":[{"type":"tool_use","id":"tu_01",
"name":"get_order_status","input":{"order_id":"123"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_01",
"content":"{\"status\":\"shipped\",\"eta\":\"2026-06-05\",\"carrier\":\"SF\"}"}]}
] }
// ④ 模型这次才给出文字答复:"您的耳机已发货,预计 6 月 5 日由顺丰送达。"
模型返回 tool_use 之后,这轮对话结束了吗?前端能直接把它展示给用户吗?
展开答案
没结束。tool_use 只是个中间步——它是模型的"提议",不是给用户的答复。你必须执行工具、把结果喂回、再让模型生成真正的答复。如果把 tool_use 直接丢给前端,用户会看到一坨 {"name":"get_order_status"…} 的 JSON。Agent 框架的循环就是在自动化这个"执行—喂回"步骤。
5结构化输出(structured output)
让模型的输出严格符合你给的 JSON Schema,而不是自由散文。
前端要的是 response.status === "shipped" 这样能用程序判断的字段;模型却天然倾向回"您的耳机已经发货啦~"。结构化输出把输出从"给人读"变成"给代码消费"。
底层机制(比文档深一层):分两个层次,差别很大。JSON mode 只保证输出是语法合法的 JSON(靠偏置 + 后校验),但形状不保证——少字段、字段名拼错、类型不对都在它的能力之外。strict schema 则把你的 schema 编译成一个有限状态机 / 语法,在生成的每一步用一个"logit 处理器"把所有违反当前语法状态的 token 概率压到负无穷再采样——所以它能保证输出形状(下一章拆这个机制)。代价:schema 要编译,递归 / 无界 pattern 等特性未必编得出来。
// OpenAI —— strict json_schema,形状有保证
"text": {"format": {"type":"json_schema","strict":true,
"schema":{"type":"object",
"properties":{"status":{"type":"string"},"eta":{"type":"string"},
"carrier":{"type":"string"}},
"required":["status","eta","carrier"],
"additionalProperties":false}}}
// Anthropic —— 2026-01 起有原生结构化输出(也可用 tool schema 约束)
"output_config": {"format": {"type":"json_schema","schema":{ /* 同上 */ }}}
// Gemini —— responseSchema / responseJsonSchema
"generationConfig": {"responseMimeType":"application/json",
"responseJsonSchema": { /* 同上 */ }}
开了 JSON mode(不是 strict schema),还会拿到非法或残缺的 JSON 吗?
展开答案
会。两种情形:① JSON mode 保语法不保形状——可能给你 {"shipping":"ok"},字段名根本不是你要的 status。② 即便形状对,输出 token 撞上 max_tokens 上限时会被截在对象中间,得到半个 JSON(finish_reason:"length")。所以解析前永远先查 finish_reason。要"形状有保证",得用 strict schema。
6采样参数(sampling)
temperature / top_p 控制模型从"下一个 token 的概率分布"里怎么挑——挑最稳的,还是允许些随机。
客服查订单要稳定可复现(低 temperature),写营销文案要多样有新意(高 temperature)。同一个模型靠这几个旋钮切换"性格"。
底层机制(比文档深一层):模型每一步输出的是一个 logits 向量,经 softmax 变成"下一个 token 的概率分布"。temperature T 在 softmax 前缩放 logits:T→0 趋近 argmax(永远挑概率最高的那个),T>1 把分布拉平(更随机)。top_p(核采样)只在"累计概率达到 p 的最小 token 集合"里挑;top_k 只看概率前 k 个。这章先记住效果,下一章会讲一个反直觉的事实:temperature=0 也不保证逐字节可复现。
Claude 的新模型(含 Opus 4.8)同时设置 temperature 和 top_p 会直接 400 报错——只能二选一。把"在 OpenAI 上习惯的两个都填"照搬过去,第一个请求就失败。
把 temperature 设成 0,同一个请求连发两次,输出一定逐字节相同吗?
展开答案
不一定。这正是下一章要拆的反直觉点——根因不在随机数,而在 GPU kernel 不是"批次不变"的。先把这个疑问揣着,到 02 章 揭晓。
7上下文窗口 + prompt caching
上下文窗口是单次调用能容纳的 token 上限(输入 + 输出共享);prompt caching 复用重复前缀的计算,省钱又省延迟。
窗口大小决定你能塞多长的文档、多少轮历史。不懂缓存,那个又长又固定、每轮都重发的 system prompt 会让账单线性爆炸。
底层机制(比文档深一层):注意力机制会把每个 token 的 Key/Value 张量缓存下来(KV cache),让第 N 步复用前面算好的 K/V 而不是重算——所以成本和延迟随输入和输出 token 都线性增长,长上下文之所以贵,是因为 KV cache 占满了显存带宽。prompt caching 再进一步:把"前缀的 KV 状态"整段存住,重复请求直接跳过重算——命中时读取价低至 0.1x。但它要求前缀逐字节相同:前面任何一个字符变了(比如 system prompt 顶部塞了个时间戳),后面全部失效。
上下文窗口像工作台面积;KV cache 像把已经算好的中间结果钉在台面上,省得重算;prompt caching 像把常用的那叠资料留在台面上不收走,下次直接用。边界:台面是按"前缀"排的——只要最底下那张纸换了,上面整摞都得重新铺。
[system] 你是电商客服…(500 token 的固定规则、话术、FAQ) ← 适合缓存:逐字节不变
[user] 订单 123 到哪了? ← 每轮变化:放后面
─────────────────────────────────────────────────────────
反例(缓存永不命中):
[system] 当前时间 2026-06-02 14:03:01。你是电商客服… ← 顶部塞时间戳
每秒都变 → 前缀永远不同 → 后面 500 token 每次全价重算
把"当前时间:2026-06-02 14:03:01"放在 system prompt 的最前面,对缓存命中有什么影响?放在最后面呢?
展开答案
放最前面:每秒都变 → 整个前缀逐字节不同 → 缓存永不命中,那 500 token 的固定规则每次全价重算。放最后面(在固定规则之后):固定前缀仍能命中,只有时间戳那一小段重算。结论:动态内容一律放在缓存边界之后。这条在第 02 章会量化成具体的钱。
8新成员:reasoning / thinking 模型
新一代模型在给出答复前,先生成一段"思考"token;你用 effort / thinking budget 控制它想多久,思考 token 按输出计费、通常不可见。
复杂任务——多步数学、规划、代码——靠"想清楚再答"能显著提升正确率。客服场景里,"如果我今天下单,赶得上周五前到吗?"需要算日期 + 查物流时效 + 推理,正是 reasoning 模型的用武之地。
底层机制(比文档深一层):模型先自回归生成一段隐藏的 reasoning token,再生成最终答复。reasoning token 计入输出 token 计费,但通常只返回摘要或完全不返回。控制旋钮各家不同:Anthropic 用 effort(2026-02 起,Opus 4.8 默认 high)+ adaptive thinking(按需才思考);OpenAI 用 reasoning_effort(minimal/low/medium/high/xhigh);Gemini 用 thinking levels。代价:更慢更贵(思考也烧 token);在简单任务上开高 effort 是纯浪费。
像让人"打草稿再誊正":草稿(reasoning token)你看不到,但纸钱(token 费)照付。边界:你能调"草稿打多久"(effort),但通常不能看到草稿内容——各家默认隐藏或只给摘要。
这是当前变动最快的一块(见起点的"现状速览")。把它当成"在前七个原语之上多了一个隐藏的生成阶段"来理解就够了——它没有推翻任何原语,只是在"模型 emit token"这一步前面加了一段你付费但看不见的 token。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 用一句话说明,为什么"多轮对话上下文丢失"通常是你的代码的责任,而不是 API 的 bug。
tool_use出现在模型的响应里时,真正执行那个函数的是谁?模型知道函数被执行了吗?- JSON mode 和 strict schema 都能让模型"输出 JSON",它们各自不保证什么?
- (设计题)客服 system prompt 有 800 token 固定话术 + 每轮变化的订单号。为降低成本,固定与可变内容应该怎么排,为什么?
答案(先做完再展开)
- 因为服务器无状态——每次调用只看你这次发的消息列表。要让模型记得上文,必须由你的代码把历史一路带上;没带,就丢了。
- 真正执行的是你的代码。模型只 emit 了一段表示"想调用 X(参数)"的 token,它不知道、也无从知道函数是否真被执行——它下一轮只是看到历史里多了一条
tool_result消息。 - JSON mode 不保证形状(字段可能缺 / 错 / 类型不符),也不保证完整(撞上 max_tokens 会截断成半个对象)。strict schema 保证形状,但代价是 schema 需可编译,部分特性(递归等)不支持。
- 固定话术放最前面、订单号放最后面。prompt caching 按逐字节前缀匹配:固定话术在前且不变 → 每轮命中、读取价 0.1x;可变的订单号在后 → 只有它后面那一小段重算。反过来把订单号放前面,前缀每轮都变,800 token 全价重算。
把七个原语在一次"流式 + 工具调用"里串起来
设想客服助手开了流式、又需要调用工具。当模型决定调用 get_order_status 时,它的 tool_use 参数(一段 JSON)也是流式一块块到的。问题:你能在工具参数 JSON 还没收完时就执行工具吗?这对前端"边生成边展示"意味着什么取舍?
提示(卡住再展开)
回到第 3 节关于流式的代价、第 5 节关于"半个 JSON 没法解析"。工具参数必须完整才能执行——所以流式工具调用时,你只能等参数那一段的 delta 拼完(收到该 content block 的 stop 事件)才执行。这就是"流式擅长给人看的文字、却不擅长原子对象"的具体体现。