配套代码:本仓库的 provider 层(src/anna/providers/)与 LLM 统一模型调用服务 (src/anna/server/main.py

学完本节,你应该能回答:一次 LLM 调用请求长什么样?参数怎么影响输出? token 怎么算?怎么让模型稳定返回 JSON?出错怎么办?多个模型怎么统一调?

目录

  1. Chat Completions / Responses API
  2. 模型参数:temperature、top_p、max_tokens
  3. Token 与 Context Window
  4. Structured Output
  5. 错误类型与异常处理
  6. 多模型调用基础

1. Chat Completions / Responses API

1.1 请求结构:以 OpenAI Chat Completions 为标准

主流模型 API 基本都是"HTTP POST + JSON 请求体",OpenAI 的 POST /v1/chat/completions 是事实标准。先看完整请求:

curl -s https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [
      {"role": "system", "content": "你是英语陪练 Anna。"},
      {"role": "user", "content": "什么是 token?"}
    ],
    "temperature": 0.7,
    "max_tokens": 200,
    "stream": false
  }'

请求体字段(重要程度递减):

字段说明
model模型名,决定用哪个模型、什么价格
messages对话历史,role + content 的有序列表
temperature采样随机性(0~2)
top_p核采样阈值(0~1)
max_tokens输出 token 上限
stream是否流式返回
response_format结构化输出声明(后文第 4 节)

消息格式:核心是 messages 数组,四种角色:

role用途例子
system设定人格/规则,整段对话生效“你是英语陪练 Anna”
user用户输入“什么是 token?”
assistant模型历史回复(多轮对话必须回传)“Token 是……”
tool工具调用结果(配合 function calling)查词工具返回的释义

返回结构

{
  "id": "chatcmpl-xxxx",
  "object": "chat.completion",
  "model": "gpt-4o-mini",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "Token 是模型处理文本的基本单位……"},
      "finish_reason": "stop"
    }
  ],
  "usage": {"prompt_tokens": 18, "completion_tokens": 42, "total_tokens": 60}
}

三个关键点:内容在 choices[0].message.contentfinish_reason 表示结束原因 (stop 正常结束 / length 达到 max_tokens 被截断 / content_filter 被过滤); usage 返回 token 用量(第 3 节细讲)。

1.2 Responses API(新接口,先认识)

OpenAI 后来又推出 POST /v1/responsesresponses.create),能力与 Chat Completions 重叠但结构不同:消息平铺在 input 字段、系统提示用 instructions、 工具调用更统一。对初学者:先吃透 Chat Completions 结构,Responses 只是同一套 概念的另一种表达。本仓库的网关对外暴露的就是 Chat Completions 兼容接口。

官方调用例子(curl)

curl -s https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o-mini",
    "instructions": "你是英语陪练 Anna。",
    "input": "什么是 token?",
    "max_output_tokens": 200
  }'

返回结构(简化)

{
  "id": "resp_xxxx",
  "object": "response",
  "model": "gpt-4o-mini",
  "output": [
    {
      "type": "message",
      "role": "assistant",
      "content": [{"type": "output_text", "text": "Token 是模型处理文本的基本单位……"}]
    }
  ],
  "usage": {"input_tokens": 15, "output_tokens": 42, "total_tokens": 57}
}

Python(openai SDK)

from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
    model="gpt-4o-mini",
    instructions="你是英语陪练 Anna。",
    input="什么是 token?",
)
print(resp.output_text)  # 便捷属性:拼好的文本

Responses ↔ Chat Completions 字段对照

概念Responses APIChat Completions
系统提示instructions(顶层)messagesrole: "system"
对话内容input(字符串或消息数组)messages
输出上限max_output_tokensmax_tokens
返回文本output[].content[].textchoices[0].message.content
用量字段input_tokens / output_tokensprompt_tokens / completion_tokens

注意:本仓库网关目前没有 /v1/responses 端点(见附录对照表,标为未实现)。 上面的例子调用的是 OpenAI 官方 API;后续若做"兼容 shim"(把 /v1/responses 转成内部 Chat Completions 再走现有 provider),这段请求就能打到自己的网关, 属于不错的练习方向。

1.3 主流厂商协议对照

厂商端点差异点本项目 adapter
OpenAI 系(含 DeepSeek/DashScope 兼容模式)POST /v1/chat/completions事实标准openai_compatible.py
AnthropicPOST /v1/messagessystem 独立字段、max_tokens 必填、content 是块数组anthropic.py
Google GeminiPOST /v1beta/models/{model}:generateContent角色叫 user/model、system 叫 systemInstructiongemini.py

所以"统一模型调用"的本质是:定义一个统一接口,让各家 adapter 负责格式转换 (本仓库的 ChatProvider 就是这个统一接口)。

Anthropic 调用例子

curl -s https://api.anthropic.com/v1/messages \
  -H "Content-Type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 200,
    "system": "你是英语陪练 Anna。",
    "messages": [{"role": "user", "content": "什么是 token?"}]
  }'
# 返回:{"content":[{"type":"text","text":"..."}],"usage":{"input_tokens":15,"output_tokens":42}}

Gemini 调用例子

curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \
  -H "Content-Type: application/json" \
  -H "x-goog-api-key: $GOOGLE_API_KEY" \
  -d '{
    "systemInstruction": {"parts": [{"text": "你是英语陪练 Anna。"}]},
    "contents": [{"role": "user", "parts": [{"text": "什么是 token?"}]}],
    "generationConfig": {"maxOutputTokens": 200}
  }'
# 返回:{"candidates":[{"content":{"parts":[{"text":"..."}]}}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":42}}

1.4 在本项目里调用

启动网关后,用 curl 走统一入口:

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"auto","messages":[{"role":"user","content":"用一句话解释 token"}]}'

或者在 Python 里直接用 provider 层:

from anna.providers import get_provider_chain
provider = get_provider_chain()          # 读取 ANNA_* 环境变量
reply = provider.complete([
    {"role": "system", "content": "你是英语陪练 Anna。"},
    {"role": "user", "content": "什么是 token?"},
], max_tokens=200)
print(reply)

2. 模型参数:temperature、top_p、max_tokens

2.1 temperature:控制随机性

模型每次回答是从概率分布里"抽样",temperature 决定抽样的激进程度:

temperature行为适用
0几乎每次都选概率最高的词,稳定结构化输出、查词、代码
0.7平衡日常对话
1.0+更发散、更有创意头脑风暴、文案

同一句话,temperature=0 时两次回答几乎一致;temperature=1 时两次可能不同:

问:给猫起个名字
temp=0   → "咪咪"
temp=1   → "毛球"  (第二次可能是 "年糕")

在网关里传参:

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"auto","temperature":0,"messages":[{"role":"user","content":"给猫起个名字"}]}'

Python 里对比两次调用:

from anna.providers import get_provider_chain
provider = get_provider_chain()
prompt = [{"role": "user", "content": "给猫起个名字"}]
print(provider.complete(prompt, temperature=0))   # 每次几乎一样
print(provider.complete(prompt, temperature=1))   # 每次可能不同

2.2 top_p:核采样

top_p 表示"只从累计概率达到 p 的最可能词里采样"。p=1 用全部词表;p=0.1 几乎只用 最高概率的词,效果类似低 temperature。

OpenAI 的建议:temperature 和 top_p 改一个就好,不要同时调——两者都在控制 随机性,同时调会互相干扰。

在原始 OpenAI API 里传参:

curl -s https://api.openai.com/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"写一句关于秋天的诗"}],"top_p":0.1}'

(本项目网关当前暴露 temperature / max_tokenstop_p 作为扩展字段可透传到 OpenAI 兼容厂商,属于课程后续练习。)

2.3 max_tokens:输出预算

  • 控制单次回复的长度上限,也直接控制成本(按 token 计费);
  • 如果回答超长被截断,返回的 finish_reason 会是 length,而不是 stop
  • 设置过小(如 10)会看到"话没说完":
{"choices": [{"message": {"content": "Token 是模型处理文本的基本单"}, "finish_reason": "length"}]}

在网关上用 max_tokens=10 复现截断(返回内容很短、可能话没说完):

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"auto","max_tokens":10,"messages":[{"role":"user","content":"用 200 字介绍 token"}]}'

排查技巧:看到 finish_reason: length,先加 max_tokens,再考虑换更大 context window 的模型。

说明:网关组装响应时目前固定返回 finish_reason: "stop"openai_format.py), 上游的 length / content_filter 结束原因暂未透传,属于可扩展项;上文的 length 示例描述的是各家原始 API 的行为。


3. Token 与 Context Window

3.1 Token 是什么

模型不按"字"理解文本,而是按 token(词元):把文本切成一串基本单位。

  • 英文:大约 4 个字符 ≈ 1 token(“token” ≈ 1 token,“streaming” ≈ 2~3 token)
  • 中文:一般 1 个汉字 ≈ 1~2 token
  • 数字/标点/空格也占 token
输入:  "Anna is a reading assistant。"
分词:  ["Anna", " is", " a", " reading", " assistant", "。"]
约 6 token

用 Python 估算(精确计数用 tiktoken,首次使用需联网下载编码表):

def estimate_tokens(text: str) -> int:
    """粗略估算:英文按 4 字符/token,中文按 1 字/token。"""
    ascii_chars = sum(1 for ch in text if ord(ch) < 128)
    cjk_chars = len(text) - ascii_chars
    return ascii_chars // 4 + cjk_chars
print(estimate_tokens("Anna is a reading assistant。"))   # 例如 6~7
# 精确版(cl100k_base 与 GPT-4o 系列同族):
# import tiktoken
# enc = tiktoken.get_encoding("cl100k_base")
# print(len(enc.encode("Anna is a reading assistant。")))

3.2 Context Window(上下文窗口)

context window = 模型一次能"看"的输入 + 输出总预算。例如 32K 的模型:

输入 12K token + 输出 8K token = 20K ≤ 32K  ✓
输入 30K token + 想输出 5K   = 35K > 32K  ✗(需要压缩输入或减小输出)

代码里检查预算:

def fits_context(input_tokens: int, output_tokens: int, window: int) -> bool:
    return input_tokens + output_tokens <= window
assert fits_context(12_000, 8_000, 32_000) is True
assert fits_context(30_000, 5_000, 32_000) is False

超限的常见表现:请求被拒(context_length 类错误)、或回答被截断。应对手段:

  1. 分块:长文按段落/标题切块,每次只喂相关块 (注:Anna 的文章导入与分块属于 Phase 0 后续任务,当前仓库尚未实现,这里只讲思路);
  2. 摘要:把旧对话压成 summary;
  3. 缓存:固定前缀(system prompt)命中 prompt cache 降本。

3.3 在本项目里看 token

每次调用后网关会记录 usage:

{"usage": {"prompt_tokens": 18, "completion_tokens": 42, "total_tokens": 60}}

页面"用量统计"面板或 GET /v1/usage/summary 都能看到累计 token 与成本估算 (价格表在 src/anna/usage.pyPRICING)。

Python 里读单次用量并估算成本:

from anna.providers import get_provider_chain
from anna.usage import estimate_cost
provider = get_provider_chain()
provider.complete([{"role": "user", "content": "你好"}])
usage = provider.last_usage                 # 各家原始 usage 形状
print(provider.model, usage)
cost = estimate_cost(
    provider.model,
    usage.get("prompt_tokens", 0),
    usage.get("completion_tokens", 0),
)
print(f"cost ≈ ${cost:.6f}")

4. Structured Output

4.1 为什么需要

自由文本不可靠:解析模型回答做后续逻辑时,一个多出来的标点就可能让程序崩。 结构化输出 = 让模型"必须"返回可解析、可校验的数据

三种常见方式(由弱到强):

方式说明可靠性
Prompt 约定“只输出 JSON”低(模型可能加注释/文字)
JSON Schema用 schema 约束字段与类型,模型原生遵循
Pydantic 校验结果出来后用 Pydantic 模型解析校验兜底

4.2 在本项目里的实现

网关的 /v1/chat/completions 支持 response_format

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "auto",
    "messages": [{"role": "user", "content": "查词:serendipity"}],
    "response_format": {"type": "json_object"}
  }'

三家厂商的落地方式(见 tests/test_structured_output.py):

  • OpenAI 兼容response_format 原样透传(模型原生支持);
  • Gemini:映射成 generationConfig.responseMimeType: "application/json"
  • Anthropic:用隐藏工具强制 JSON(模型没有 response_format,就用 tool 协议约束)。

更严格的 JSON Schema 版本:

-d '{
  "model": "auto",
  "messages": [{"role": "user", "content": "查词:serendipity"}],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "type": "object",
      "properties": {
        "word": {"type": "string"},
        "meaning": {"type": "string"},
        "example": {"type": "string"}
      },
      "required": ["word", "meaning", "example"]
    }
  }
}'

Python 里直接用 provider 拿结构化输出:

import json
from anna.providers import get_provider_chain
provider = get_provider_chain()
raw = provider.complete(
    [{"role": "user", "content": "查词:serendipity"}],
    response_format={"type": "json_object"},
)
data = json.loads(raw)          # 有了 response_format,raw 一定是合法 JSON
print(data["word"], data["meaning"])

4.3 拿到 JSON 之后:用 Pydantic 校验

from pydantic import BaseModel, ValidationError
class Vocab(BaseModel):
    word: str
    meaning: str
    example: str = ""
raw = '{"word": "serendipity", "meaning": "机缘巧合", "example": "What a serendipity!"}'
try:
    item = Vocab.model_validate_json(raw)
    print(item.word, item.meaning)
except ValidationError as exc:
    print("格式不符,重试或报错:", exc)

工程原则:模型输出先过 Pydantic,校验失败就重试或按错误处理(见第 5 节), 不要把未校验的字符串直接塞进业务逻辑。


5. 错误类型与异常处理

5.1 错误类型速查

状态码类型含义处理建议
400invalid_request参数/格式错误修请求,别重试
401/403authenticationkey 无效/无权限检查 key
404not_found模型不存在/已下线换模型
408 / 网络超时timeout请求超时退避重试
429rate_limit限流/额度耗尽退避重试或切换模型
500/502/503/504server_error服务端故障退避重试
响应不合法output_formatJSON 解析失败/结构缺失重新生成或校验兜底

5.2 在本项目里怎么处理

provider 层把所有失败归一成 ProviderError,并分类(classify_provider_error, 见 src/anna/providers/base.py):

from anna.providers import ProviderError
from anna.providers.base import classify_provider_error
try:
    reply = provider.complete([{"role": "user", "content": "hi"}])
except ProviderError as exc:
    kind = classify_provider_error(exc)   # "rate_limit" / "authentication" / ...
    if kind == "rate_limit":
        # 退避后重试,或切到下一个候选模型
        pass
    elif kind == "authentication":
        raise  # 改 key,重试没用

自己写重试也可以这么干(框架层已内置同样思路,这里演示原理):

import time
from anna.providers import ProviderError
from anna.providers.base import (
    ERROR_KIND_NETWORK,
    ERROR_KIND_RATE_LIMIT,
    ERROR_KIND_SERVER,
    classify_provider_error,
)
def call_with_retry(provider, messages, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            return provider.complete(messages)
        except ProviderError as exc:
            kind = classify_provider_error(exc)
            if kind not in (ERROR_KIND_NETWORK, ERROR_KIND_RATE_LIMIT, ERROR_KIND_SERVER) or attempt == max_retries:
                raise
            time.sleep(0.5 * (2 ** attempt))   # 0.5s → 1s → 2s

重试策略(内置在 _request_json):对 429/5xx 做指数退避 (0.5s → 1s → 2s…),ANNA_MAX_RETRIES 可配;不可回退错误(鉴权/内容过滤/ 无效请求)不重试、不切模型,直接抛给上层。

回退策略FallbackProvider):主模型失败且错误类型可回退时,按顺序切 fallback;主模型连续失败 3 次熔断 60 秒,避免一直打一个坏端点:

export ANNA_FALLBACKS="dashscope:qwen3.7-max,anthropic"

限流:网关按客户端令牌桶限流(ANNA_RATE_LIMIT_RPM),超限返回 429 + Retry-After

{"error": {"message": "rate limit exceeded", "type": "rate_limit", "code": 429}}

6. 多模型调用基础

6.1 为什么要"统一"

直接对接各家 SDK 的问题:

  1. 协议不同:Chat Completions / Messages / GenerateContent 结构不一样;
  2. 切换成本高:换模型要改调用代码;
  3. 没有回退:一个模型挂了,整个功能就挂了;
  4. 成本难统计:不同模型计费规则不同。

解法 = 统一接口 + 适配器 + 注册表 + 配置驱动(本仓库 provider 层就是 这个结构的练习实现)。

6.2 统一接口

class ChatProvider:
    def complete(self, messages, *, temperature=None, max_tokens=None,
                 response_format=None) -> str: ...
    def stream(self, messages, ...) -> Iterator[str]: ...
    def supports(self, feature: str) -> bool: ...

上层代码只认这个接口,不管背后是 DashScope 还是 Ollama。

6.3 模型路由:三种写法

网关的 model 字段支持三种路由方式(见 src/anna/router.py):

# 1) auto:用 models.json 清单里"最近到期"的候选(免费额度优先用完)
-d '{"model": "auto", ...}'
# 2) 显式路由:厂商:模型
-d '{"model": "dashscope:qwen3.7-max", ...}'
# 3) 纯模型名:按关键词自动识别(qwen*→dashscope,claude*→anthropic,llama*→ollama)
-d '{"model": "qwen3.7-flash", ...}'
from anna.router import ModelRouter
router = ModelRouter(settings)
assert router.resolve("auto") == ("dashscope", "qwen3.7-max")
assert router.resolve("dashscope:qwen3.7-max") == ("dashscope", "qwen3.7-max")
assert router.resolve("claude-sonnet-4-5")[0] == "anthropic"

6.4 厂商元数据(ProviderSpec)

每个厂商的默认端点、key 环境变量、是否本地、thinking 注入方式都是声明式数据 (src/anna/providers/specs.py),新增厂商不用写代码:

SPECS["dashscope"] = ProviderSpec(
    "dashscope", keywords=("qwen", "dashscope"),
    env_key="DASHSCOPE_API_KEY", backend="openai",
    default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
    thinking_style="enable_thinking",
)

6.5 配置示例(本地 + 云端)

# 云端(DashScope 免费模型)
export ANNA_PROVIDER=openai
export ANNA_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export ANNA_API_KEY=$DASHSCOPE_API_KEY
# 本地(Ollama,无需 key)
export ANNA_PROVIDER=ollama
export OLLAMA_BASE_URL=http://localhost:11434/v1
export OLLAMA_MODEL=llama3.1

小结

  • 一次 LLM 调用 = POST 一个 JSON:model + messages + 采样参数 + 输出预算
  • temperature/top_p 控制随机性,max_tokens 控制成本与长度;
  • token 是计费与 context window 的基本单位,超限靠分块/摘要/缓存解决;
  • 结构化输出 = 原生 response_format + Pydantic 校验兜底;
  • 错误要先分类再决定"重试 / 切模型 / 直接报错";
  • 多模型支持靠"统一接口 + adapter + 注册表 + 配置驱动",而不是堆厂商代码。

动手练习:启动网关,用 curl 分别试 temperature=0temperature=1 同一个 问题对比两次输出;再用 response_format={"type":"json_object"} 让模型返回 JSON, 并用 Pydantic 校验;最后故意把 ANNA_FALLBACKS 配一个失效模型,观察自动回退。


附录:讲义内容 × 项目实现对照

快速核对"讲义里讲的,仓库里有没有"。

讲义条目项目实现位置状态
统一请求/消息格式(Chat Completions)server/schemas.pyproviders/openai_compatible.py✅ 已实现
Responses API 对比—(认识性内容)📘 通用说明
Anthropic / Gemini 协议转换providers/anthropic.pyproviders/gemini.py✅ 已实现
temperature网关 + 三家 adapter 均透传✅ 已实现
top_p⬜ 未实现(讲义已注明为扩展项)
max_tokens网关 + 三家 adapter✅ 已实现
finish_reason(length/content_filter)透传⬜ 未实现(网关固定返回 stop)
Token 用量统计 / 成本估算usage.pyGET /v1/usage/summary✅ 已实现
长文分块 / 摘要 / prompt cache⬜ Phase 0 后续任务(讲义仅讲思路)
结构化输出 json_object / json_schema网关 + OpenAI 透传 + Gemini 映射 + Anthropic 隐藏工具✅ 已实现(有测试)
Pydantic 校验模型输出—(项目内 Pydantic 用于请求校验 server/schemas.py📘 讲义示例,待接入
ProviderError + 错误分类providers/base.py classify_provider_error✅ 已实现
重试(指数退避)providers/base.py _request_json✅ 已实现
跨模型回退 + 熔断providers/fallback.pyANNA_FALLBACKS✅ 已实现
限流 429 + Retry-Afterserver/rate_limit.py✅ 已实现
统一接口 ChatProviderproviders/base.py✅ 已实现
模型路由(auto / 厂商:模型 / 关键词)router.py✅ 已实现
ProviderSpec 厂商元数据providers/specs.py✅ 已实现
云端 + 本地(Ollama)配置环境变量 + providers/ollama.py✅ 已实现

图例:✅ 已实现并有测试 / ⬜ 未实现(讲义中已注明或属后续阶段)/ 📘 通用知识或示例说明。