LLM API调用与流式输出#
一句话答案#
LLM API 本质是一个无状态的 HTTP 接口:每次把完整 messages(system/user/assistant/tool)连同 model、temperature、tools 等参数发过去,拿回 choices + finish_reason + usage;流式模式把一次响应拆成 SSE 的
data:增量 chunk,客户端按 delta 拼接、以[DONE]结束。工程上真正拉开差距的是 token 预算管理(计数、裁剪历史)和可靠性(超时、429/5xx 退避重试、限流、多供应商兼容)。
核心要点
1. 消息结构与无状态#
Chat Completions 风格(OpenAI 定义、业界事实标准)的请求核心是 messages 数组,每条有 role + content:
| role | 作用 | 备注 |
|---|---|---|
| system | 设定身份/规则,通常放第一条 | OpenAI 新模型用 developer 角色,语义相同;Responses API 改用顶层 instructions;Anthropic 的 system 是顶层参数,不在 messages 里 |
| user | 用户输入,可含多模态(text + image_url) | |
| assistant | 模型历史回复;含 tool_calls 时 content 可为空 | 多轮时由客户端回填 |
| tool | 工具执行结果,必须带 tool_call_id 与上一条 assistant 的调用配对 | 见 [Function Calling与工具编排](/topics/ai-agent/Function Calling与工具编排) |
服务端不保存会话:模型每次只”看到”本次请求里的 messages,所以多轮对话必须把历史全量重发——这就是为什么上下文越聊越长、越聊越贵,也是为什么 prompt caching 能省钱(前缀相同的部分命中缓存,usage.prompt_tokens_details.cached_tokens 会体现)。OpenAI 现在给新项目推荐的是 Responses API(Chat Completions 仍受支持;Assistants API 已于 2026-08-26 下线):请求用 input + instructions,输出是按类型区分的 item 数组(message、reasoning、function_call 等),可以用 previous_response_id 或 Conversations API 让服务端保存状态,响应默认会被存储(store: false 关闭)。即便如此,Chat Completions 形态仍是各家 OpenAI 兼容端点的公共格式,面试默认按无状态理解。
2. 核心参数(只点名,细节见 解码策略与采样参数)#
- model:决定能力、价格、上下文窗口;temperature / top_p:随机性,官方建议二选一调;max_completion_tokens(旧参数
max_tokens已标废弃、不兼容 o 系列推理模型):输出上限,包含推理 token,注意它和上下文窗口是两回事;stop:停止序列;n / seed:多候选与可复现;tools / tool_choice:工具声明与强制调用;response_format:json_object或json_schema(见 结构化输出与约束解码);stream / stream_options:是否流式、流式是否返回 usage;safety_identifier / prompt_cache_key:旧的user字段已标废弃,拆成这两个——前者是终端用户标识(建议传哈希值),供厂商做滥用追踪,后者用于提高缓存命中和按用户分开计缓存(见 [前缀缓存与KV Cache复用](/topics/llm-serving/前缀缓存与KV Cache复用))。
3. 返回结构与 finish_reason#
{ "id": "...", "model": "...",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "...", "tool_calls": [...] }, "finish_reason": "stop" }],
"usage": { "prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200,
"prompt_tokens_details": { "cached_tokens": 64 }, "completion_tokens_details": { "reasoning_tokens": 0 } } }plaintextfinish_reason 是代码必须分支处理的字段:stop(自然结束或命中 stop 序列)、length(撞到 max_tokens 或上下文上限,答案被截断——要么加大 max_tokens,要么续写)、tool_calls(模型要调工具,content 通常为空,进入工具执行循环)、content_filter(被厂商安全策略拦截)。Anthropic 对应字段叫 stop_reason:end_turn / max_tokens / stop_sequence / tool_use 与上面基本对应,另外还有 refusal(安全策略拒答)、pause_turn(服务端工具循环到了迭代上限,需要把响应原样发回继续)、model_context_window_exceeded(撞到上下文窗口);所有 stop_reason 都随 HTTP 200 正常返回,不是错误。usage 用来计费、做限流和成本归因(见 AI应用成本优化),cached_tokens 单价通常只有正常输入的几分之一,是长 system prompt 场景的主要省钱点。
4. 流式输出的底层#
stream=true 时响应头是 Content-Type: text/event-stream,走 SSE:
data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}} ← 仅 stream_options.include_usage=true
data: [DONE]plaintext- 每帧是
data: <json>\n\n,对象类型变成chat.completion.chunk,message变成 delta(增量);客户端按choices[i].index把delta.content字符串追加即可 - finish_reason 只在最后一个内容帧非 null,usage 只在末尾单独一帧(且 choices 为空)——所以流式下”用了多少 token”要等流结束才知道,客户端中途断开就拿不到
- 工具调用也是增量的:
delta.tool_calls[i]第一帧给id+function.name,后续帧function.arguments是 JSON 字符串碎片,按 index 拼完整后才能 parse - 其他格式:OpenAI Responses API 的流式是带类型的语义事件,如
response.created→ 多个response.output_text.delta/response.function_call_arguments.delta→response.completed,出错为error事件;Anthropic 是event:+data:成对出现,顺序为message_start→ 每个内容块content_block_start/ 若干content_block_delta(text_delta、input_json_delta、thinking_delta)/content_block_stop→message_delta(带stop_reason和累计 usage)→message_stop,中间可能夹ping。解析器要按事件类型分支,不能假设只有一种 chunk 格式 - 流式的价值是把感知延迟从总时长降到首 token 时间(TTFT);前后端怎么传(SSE vs WebSocket、Nginx 缓冲、事件协议设计)属于应用层,见 流式输出与实时交互
5. Token 计数与上下文预算#
- 计数:用与模型配套的 tokenizer 离线估算——OpenAI 用
tiktoken(GPT-4o、GPT-4.1、GPT-5、o 系列是o200k_base,GPT-4/3.5 是cl100k_base,新模型以 tiktoken 的模型映射表为准);Anthropic 等不公开 tokenizer 的厂商用官方 token 计数接口;Java 可用 jtokkit;国产模型用厂商提供的 tokenizer 或计数接口。每条 message 有几个 token 的格式开销,汉字通常比英文更”贵”且各 tokenizer 差异大(见 Tokenizer与分词原理),估算留 10–20% 余量 - 预算关系:
prompt_tokens + max_completion_tokens ≤ context_window,超出直接 400(context_length_exceeded),不会自动截断 - 裁剪历史:固定保留 system + 最近 N 轮(滑动窗口);更长对话用”旧历史摘要 + 近几轮原文”;RAG 场景先压缩检索内容。裁剪时成对删除 user/assistant,且 tool 消息不能与对应 assistant 的 tool_calls 拆开,否则 400。工程化的记忆管理见 Agent记忆与上下文工程
6. 可靠性工程#
| 问题 | 做法 |
|---|---|
| 超时 | 连接超时短(几秒)、读超时按输出长度给(非流式几十秒到几分钟);流式改用帧间空闲超时而非总超时 |
| 429 / 5xx / 连接重置 | 指数退避 + 抖动重试(如 1s→2s→4s,上限 3–5 次),优先遵守 Retry-After;400/401/403 不重试 |
| 限流 | 厂商按 RPM(请求/分钟)和 TPM(token/分钟)双维度限;客户端用令牌桶/信号量限并发,批量任务排队;大流量申请提额或多 Key 分摊 |
| 幂等 | LLM 调用天然不幂等:重试可能重复计费、更糟的是重复执行有副作用的工具;应用层用 request_id 去重,工具侧做幂等 |
| Key 管理 | 只在服务端持有,走密钥管理/环境变量,前端一律经后端代理;按环境/服务拆 Key 便于审计与轮换 |
| 多供应商 | DashScope(通义)、DeepSeek、Moonshot、vLLM/Ollama 等都提供 OpenAI-compatible 端点,切 base_url + api_key 即可换模型;但 tools、response_format、stream_options 等细节支持度不一,要做能力探测;大规模用 LiteLLM/New API/Higress 这类网关统一入口并做故障转移。Java 侧 Spring AI 的 ChatClient(.call() / .stream() 返回 Flux)或官方 OpenAI Java SDK 已封装以上细节,见 [Spring AI核心概念与架构](/topics/ai-agent/Spring AI核心概念与架构) |
面试回答(2分钟版)
LLM API 的基本形态是 Chat Completions:一个无状态 HTTP 接口,请求核心是 messages 数组,角色有 system、user、assistant、tool。服务端不存会话,多轮每次都要全量重发历史,这是上下文越聊越贵的根源,也是 prompt caching 能省钱的原因。参数主要是 model、temperature/top_p、max_completion_tokens(旧名 max_tokens)、stop、tools、response_format。OpenAI 现在给新项目推荐 Responses API,可以让服务端存状态,但各家兼容端点的公共格式仍是 Chat Completions。返回里最要紧的是 finish_reason:stop 正常结束,length 被截断,tool_calls 进工具循环,content_filter 被拦;usage 的 prompt/completion/cached tokens 用来计费。流式是 stream=true 走 SSE,每帧
data:一个 chunk,message 变成 delta,按 index 拼 content,finish_reason 在最后一个内容帧,usage 要开 include_usage 才在末尾单独一帧,最后[DONE];工具参数也是 JSON 碎片,拼完才能 parse。工程上第一管 token 预算:tiktoken 估算,prompt 加 max_tokens 超窗口直接 400,要滑动窗口或摘要裁剪历史。第二可靠性:429 和 5xx 指数退避加抖动重试、遵守 Retry-After,400 不重试;限流分 RPM 和 TPM;重试要考虑幂等,别把有副作用的工具跑两遍。第三 Key 只放服务端,多供应商用 OpenAI-compatible 端点切 base_url,DashScope、DeepSeek 都兼容,大规模上网关统一。
追问与易错
追问方向:
- 为什么每轮都要把历史全发?不能只发新消息吗? → Chat Completions 是无状态的,服务端不存上下文,模型只看本次请求;要”只发增量”得用带服务端状态的接口(如 Responses API 的 previous_response_id)或自己在应用层存历史。全量重发的代价靠 prompt caching 摊薄,命中部分按 cached_tokens 折扣计费
- 流式模式下怎么拿到 token 用量? → 请求里加
stream_options: {"include_usage": true},usage 会在最后一帧单独返回(choices 为空);客户端中途断开拿不到,只能用 tokenizer 估算已收到的内容 - 429 了怎么办?是不是所有错误都重试? → 429 和 5xx、连接重置做指数退避 + 抖动重试,有 Retry-After 就照它来;400(参数错/上下文超限)、401/403 重试没意义。还要区分 429 是 RPM/TPM 限流还是额度用尽
- 国内模型怎么接?换厂商要改多少代码? → 通义 DashScope、DeepSeek 等都提供 OpenAI 兼容端点,SDK 改 base_url 和 key 即可;差异在 tools/response_format/流式 usage 等细节支持度,要做能力探测和回退,生产建议网关统一
易错点:
- ❌ “流式每一帧都有 usage 和 finish_reason” → finish_reason 只在最后一个内容帧非 null,usage 只在开启 include_usage 后的末尾单独一帧
- ❌ “上下文超了模型会自动截断旧的” → 直接报 400,裁剪历史是客户端的责任
- ❌ “重试是安全的,多试几次就行” → 重试会重复计费,更危险的是重复执行有副作用的工具调用,需要 request_id 去重与工具幂等