把大模型接进生产系统,真正的工作量不在「发出一次 HTTP 请求」,而在围绕调用的整条工程链路:消息怎么组织、流式数据怎么传、Token 怎么折算成钱、失败要不要重试、超额怎么限流、密钥交给谁保管。本文按一次真实调用的生命周期,把这些环节逐一拆开。

消息结构与角色体系

Chat Completions 风格的 API 都以 messages 数组为核心,每条消息携带一个 role:

  • system:系统指令,定义模型身份、行为边界与输出要求,优先级最高,应该写业务规则而非寒暄。
  • user:用户输入。
  • assistant:模型的历史回复。多轮对话的「记忆」靠把历史 assistant 消息重新发回去实现——模型本身是无状态的。
  • tool:工具执行结果的回传消息,需要与 assistant 消息中的 tool_calls 一一对应。
messages = [
{"role": "system", "content": "你是客服助手,仅依据提供的政策回答。"},
{"role": "user", "content": "七天前买的耳机能退吗?"},
{"role": "assistant", "content": "请问订单号是多少?我先查一下签收时间。"},
{"role": "user", "content": "A20261001"},
]

两个工程要点:其一,历史消息每轮都被完整重发并重复计费,对话变长后必须有裁剪、摘要或滑窗策略;其二,system 指令不是安全边界,用户输入仍可能注入冲突指令,权限与越权控制要做在代码层,而不是寄托在提示词里。

流式输出:SSE 与取消

流式输出让模型边生成边返回,把首字延迟(TTFT)从「等整段答案」压缩到「等第一个片段」。它优化的是体感等待,不会缩短总生成时长,也不省 Token。

主流承载方式是 SSE(Server-Sent Events):响应类型为 text/event-stream,服务端逐行写 data: 字段,空行才是事件分隔符。正文含换行时每一行都要带 data: 前缀,交给框架编码器处理,不要手工把 \n 替换成字面量,否则前端还要再做一轮容易出错的反转义。

最容易踩的坑在中间层:Nginx 等反向代理默认缓冲响应,会把流式攒成批量下发,用户侧的 TTFT 瞬间退回同步接口的水平。

# Nginx 反代 SSE 的最小配置
location /api/ {
proxy_pass http://backend;
proxy_buffering off; # 关闭缓冲,边收边发
proxy_cache off;
proxy_read_timeout 300s; # 守住长生成,防沉默超时切断
}

取消必须贯穿全链:用户点了停止,前端断开只是第一步,后端要同时中断到供应商的请求、正在解析的流以及后续落库、TTS 等下游任务,否则 Token 还在持续燃烧。断流时若没有收到正常结束标记,应把本次结果标记为「已中断」,而不是悄悄把半截答案当作完整结果落库。

Token 计量与成本核算

Token 是计费单位,输入与输出分开计价,输出通常贵数倍。成本公式很朴素:

cost = (input_tokens * in_price + output_tokens * out_price) / 1_000_000

但核算有三条纪律:

  1. 以 usage 字段为准:供应商响应里的 usage 才是结算依据;本地分词器估算只用于事前预算,两者可能有偏差。
  2. 先扣预算再调用:请求进入网关时估算 input_tokens + reserved_output_tokens,在用户、租户、模型几个桶里预扣,扣不到就排队或拒绝,别等账单出来再后悔。
  3. 失败也可能计费:流式生成到一半中断,已产出的 Token 通常照样收费,成本核算要能容忍「没有完整响应但有消耗」的情况,并配以供应商账单的对账任务。

重试与限流

重试前先给错误分类,不该重试的错重试一百次也是浪费配额:

错误类型 示例 策略
网络瞬断 连接重置、读超时 可重试
供应商 5xx / 过载 502、503、529 有限重试,必要时切换模型
429 限流 RPM/TPM 超配额 尊重 Retry-After,排队或降级
4xx 请求错误 参数非法、上下文超限 不重试,修请求
鉴权失败 401/403 不重试,告警并停用密钥

重试用指数退避加抖动(jitter),避免大量客户端在同一时刻回撞;再加两条硬约束——最大重试次数(通常 2~3 次)和整体截止时间,防止重试把 15 秒的业务 SLA 拖成一分钟。

只要会重试,就必须有幂等。给每个业务消息生成幂等键(如 tenantId:userId:messageId),服务端用它做原子占位(Redis SET NX 或数据库唯一约束):已成功直接返回历史结果,进行中返回同一个流式任务的订阅地址。这样才能挡住「用户狂点重新发送」和「网关超时自动重试」两类重复扣费、重复落库。

限流也不能只看 QPS。一次 500 Token 的请求和一次 80K Token 的请求都是「1 次」,成本却差两个数量级。生产限流至少要看四个维度:RPM(每分钟请求数)、TPM(每分钟 Token 数)、并发数、租户周期预算,按用户 → 租户 → 模型 → 供应商四层设桶,令牌桶是工程上最常用的实现。请求先扣桶再发给供应商,429 只应作为外部信号,而不是唯一的容量管理手段。

超时与熔断

超时至少拆三层,混为一谈会误导排查方向:

  • 连接超时:根本连不上供应商,通常是网络或 DNS 问题。
  • TTFT 超时:连上了但迟迟没有首个事件,多指向模型排队或上下文过长。
  • 总时长超时:持续有输出但超过业务容忍度,可能只是让模型写太长了。

熔断器保护的是你的系统不被下游拖死:某供应商连续失败或 429 超过阈值,熔断器开路,流量切到备用模型;经过冷却期后半开试探,恢复则闭合。降级要有业务明示——切到轻量模型时标注「当前为快速模式」,别让质量悄悄变差。

密钥管理与代理层

密钥红线:不出现在前端代码、不进 Git 仓库、不硬编码在源码里。正确姿势是放环境变量或密钥管理服务(KMS/Vault),按环境隔离,定期轮换,泄露时能快速吊销。

所有模型调用应收口到统一的网关/代理层,由它集中处理:密钥池与鉴权、供应商路由与故障切换、配额与限流桶、日志脱敏与审计、成本归集。业务模块各自直连 SDK 的结果,是每一套都长出略有差异的超时与重试语义,出问题无法复现。调用日志建议携带 traceId、租户、模型、Token 用量、TTFT、重试次数和供应商请求 ID——缺了这些字段,用户反馈一句「AI 没返回」,你连查哪条链路都无从下手。

面试视角

SSE 上线后用户仍觉得「一顿一顿的」,先查什么? 先查中间层缓冲:Nginx、网关、Service Mesh 任意一环开了响应缓冲都会攒批下发,proxy_buffering off 是最常见的修复;再看 TTFT 分布,确认是不是模型侧排队。

为什么限流不能只按 QPS? 大模型的成本与压力由 Token 决定而非请求次数,请求之间成本可差两个数量级;需要按 RPM、TPM、并发数与租户预算组合限流,并在调用前预扣。

用户取消后为什么账单还在涨? 前端断连不等于供应商停止生成,取消信号必须沿调用链传播到供应商请求;否则流还在跑,Token 还在计费。

重试怎么保证不多扣一次钱? 幂等键加原子占位,同一业务消息只允许一个成功 attempt 落库;失败请求也可能已产生部分 Token 消耗,需要对账任务兜底。

小结

  1. 消息体系以 system/user/assistant/tool 四种角色组织;模型无状态,历史重发既是记忆机制也是成本来源。
  2. SSE 的核心细节是空行分隔与代理缓冲;取消必须传播到供应商,断流要标记中断态而非当成功。
  3. Token 以响应 usage 为准结算,调用前先预扣预算;输入输出分开计价,失败也可能产生费用。
  4. 重试按错误分类决策,指数退避加抖动,配合幂等键防重复执行;限流按 RPM/TPM/并发/预算四层设防。
  5. 超时拆连接、TTFT、总时长三层分别记录;熔断配备用模型;密钥与供应商切换统一收口到代理层。