LLM 应用后端接入工程 —— 基本功:一个普通后端服务怎么正确调大模型
你在一个 Spring Boot 服务里写下了第一行”调大模型”的代码:一个 HTTP 客户端,一个 POST /v1/chat/completions,一个手工拼出来的 JSON。跑通了,心里却越来越没底——因为你知道这只是”能跑”,离”能上生产”还差得远。
然后你去查资料。查到 MCP,讲的是”工具标准”:服务器怎么暴露工具、客户端怎么发现工具、JSON-RPC 怎么来回。查到 Agent,讲的是”循环与目标”:模型怎么一轮一轮自己把任务推进到底。但你真正想问的那个问题,没人正面回答:
我自己的后端服务,这一次调用到底该怎么写才对?
这是 LLM 应用里被跳过得最厉害的一层。大家都在往上讲 Agent、讲 MCP、讲多智能体编排,但地基上那层最朴素的东西——一个普通后端服务怎么正确地把请求发给大模型、把 token 流给浏览器、把工具结果喂回去、把 JSON 结构保证住、把钱算清楚、把故障扛住——反而成了”默认你已经会了”的盲区。
这篇文章就补这一层。它不假设你已经懂任何 Agent 框架,只假设你是一个天天写微服务的后端工程师。读完你会发现一句话贯穿始终:
所谓”LLM 后端接入工程”,八成是把你已经很熟的那套微服务稳性手段,套到一个”又慢、又贵、偶尔抽风、还会胡编”的特殊下游服务上;剩下两成,才是这门手艺特有的东西:SSE 流式、function calling、结构化输出、token 计量。
先把地图铺开。这一层一共六件事,每件事对应一个后端工程师立刻能听懂的问题:
| # | 基本功 | 回答的问题 | 对应你已有的微服务经验 |
|---|---|---|---|
| 一 | OpenAI 兼容 API | 怎么把这个”特殊下游”调用起来 | 协议 / SPI / 可替换实现 |
| 二 | SSE 流式 | 怎么把 token 一路流到浏览器 | 响应流 / 长连接 / 代理缓冲 |
| 三 | Function calling | 模型怎么”用”你的工具 | RPC 调用 / 参数校验 / 白名单 |
| 四 | 结构化输出 | 怎么让模型输出可反序列化的 JSON | DTO / 序列化 / Schema 校验 |
| 五 | Token 计量与成本 | 钱是怎么没的 | 埋点 / 计量 / 成本归集 |
| 六 | 超时·重试·降级·幂等 | 怎么让它别拖垮你的服务 | Resilience4j / 熔断 / 幂等键 |
前两件事,讲”怎么把话说清楚”(协议 + 流式);中间两件,讲”怎么让模型给你能用的东西”(工具 + 结构);最后两件,讲”怎么在真实流量里活下来”(钱 + 稳)。下面逐件拆。
一、OpenAI 兼容 API:一个”事实上的标准”
1.1 为什么大家都”兼容 OpenAI”
你现在想接 DeepSeek、通义、Kimi、智谱、Claude(经网关)、甚至本地跑一个 Ollama,打开文档会发现一件诡异的事:它们全都长一个样——都是 POST /v1/chat/completions,请求体字段几乎一模一样。
这不是巧合,是市场收敛的结果。OpenAI 的 Chat Completions API 成了 LLM 的事实标准,就像 Dubbo 里的”服务引用协议”、HTTP 里的 REST——没人立法规定,但大家都这么写,因为换个模型不用改业务代码。这就是”OpenAI 兼容”四个字的全部含义:
“兼容”指的是接口形状(路径 + 字段名 + 返回结构)一致,不是行为一致。 同样的字段,不同厂商的语义、上限、计价、超时可能完全不同。
一个后端工程师拿到这个事实后,第一反应应该是:那我就在最上面抽一层接口,把厂商差异关在实现里。 后面 1.5 节再展开。先看这个接口本身长什么样。
1.2 核心概念:model / messages / temperature / max_tokens / stream
一次最普通的”对话补全”调用,请求体就这么几个字段:
curl https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个严谨的后端工程师。"},
{"role": "user", "content": "Redis 主从复制怎么实现?"}
],
"temperature": 0.7,
"max_tokens": 1024,
"stream": false
}'
五个字段,逐个说清:
model:指定用哪个模型。这是唯一一个”厂商强相关”的字段,因为模型名是各家的私有命名空间(deepseek-chat、gpt-4o、qwen-plus……)。messages:对话历史,一个数组。这是理解 LLM 无状态的关键——模型没有记忆,每一轮你都要把”到目前为止的全部对话”原样重发(详见《大模型无状态》)。数组里每个元素有个role:system:给模型定人设、定规则(”你是一个……”)。一般放第一条,可选。user:用户说的话。assistant:模型之前的回答,回填历史用。tool:工具执行结果,回填给模型(第三部分专用)。
temperature:采样温度,0~2。越低越确定、越高越发散。写代码、抽实体、结构化输出时通常调到 0 或接近 0;写文案、头脑风暴时才调高。它不是”正确率旋钮”,但靠近 0 会让输出更可预测。max_tokens:限制这次生成最多吐多少 token。注意它只管输出、不管输入,而且是省钱开关——模型不会因为你说”短一点”就真的短,只有max_tokens是硬上限(第五部分再算账)。stream:布尔。false是”一次性等全部生成完再回”,true是”边生成边回”。这就是第二部分的主题。
返回的响应体(非流式)结构如下,其中两个字段后面会反复用到:
{
"id": "chatcmpl-xxxx",
"object": "chat.completion",
"model": "deepseek-chat",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Redis 主从复制……" },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 42,
"completion_tokens": 318,
"total_tokens": 360
}
}
choices[0].message.content:正文,你真正要的东西。choices[0].finish_reason:为什么停。常见值stop(正常说完)、length(撞到max_tokens上限被截断)、tool_calls(模型想调工具,见第三部分)。判断”是否正常结束”就靠它。usage:token 账本,第五部分的原料。
1.3 一次调用就是”把历史重发一遍”
把 messages 想成你微服务里那个”请求上下文”。模型不记得上一句说了啥,所以每次调用你都要把整个上下文重新发一遍。多轮对话在服务端就是这样一个循环:
// 伪代码:每次用户发消息,都把整段历史重发
List<Message> history = new ArrayList<>();
history.add(Message.system("你是一个严谨的后端工程师。"));
// 用户第一句
history.add(Message.user("Redis 主从复制怎么实现?"));
ChatCompletion r1 = client.chat(history); // 把 history 全发过去
history.add(Message.assistant(r1.content())); // 模型回答回填进历史
// 用户追问
history.add(Message.user("那哨兵模式和集群模式的区别呢?"));
ChatCompletion r2 = client.chat(history); // 又全发一遍(含第一轮)
history.add(Message.assistant(r2.content()));
这就是为什么上下文越长越贵:每一轮都在为之前的每一轮重复买单。长会话要么做上下文压缩、要么裁剪历史,否则 token 费用是指数级滚雪球(第五部分讲)。
1.4 “兼容”不是”一致”
这是新手最容易踩的坑。三家都叫 /v1/chat/completions,但:
| 维度 | 可能的差异 |
|---|---|
| 字段支持 | 有的支持 response_format、有的不支持 json_schema 只支持 json_object、有的不认 max_completion_tokens |
| 参数上限 | max_tokens 上限各家不同;temperature 允许范围不同 |
| 语义细节 | stream 下的 chunk 格式略有出入(有的首 chunk 带 role、有的不带) |
| 计价与缓存 | token 单价、前缀缓存命中规则各不相同 |
| 错误码 | 同样超限,一个回 400、一个回 429、一个回 500 |
所以”直接换模型名就能切厂商”是幻觉。正确姿势是抽一层接口,把厂商差异锁进实现,业务代码只依赖你自己定义的接口:
public interface ChatClient {
/** 一次性对话,阻塞返回全文 */
ChatCompletion chat(ChatRequest request);
/** 流式对话,返回增量流(第二部分展开) */
Stream<ChatChunk> stream(ChatRequest request);
}
ChatRequest 就是你要暴露给业务的最小集合(model、messages、temperature、max_tokens、stream),ChatCompletion 是统一后的结果(content、finish_reason、usage)。底下放一个 OpenAiCompatibleClient 实现,再按需加 DeepSeekClient、QwenClient…… 这个套路对你来说就是换个 SPI 实现的事,跟 Dubbo 里换注册中心、换序列化一个道理。
先定接口、后接厂商。 别让业务代码里散落
deepseek-chat和gpt-4o这种字符串——那是”配置”,不是”逻辑”。
二、SSE:把 token 一路流到浏览器
2.1 为什么要流:TTFT 与”体感”
先感受一下非流式和流式的差别。你在前端问了一句”帮我总结这段 3000 字的文档”,后端把整个请求发给模型:
- 非流式:模型把 3000 字的总结全部生成完,凑成一坨 JSON 一次性返回。你盯着空白页面等 30 秒,然后”啪”整篇砸下来。
- 流式:模型生成第一个字,立刻发给你,一个字一个字往外蹦。1 秒内你看到第一个字,之后文字持续流动。
衡量这个体验的指标叫 TTFT(Time To First Token,首 token 延迟)。同样是”总耗时 30 秒”,流式的 TTFT 可能只有 0.8 秒——用户已经看到东西在动了,等待感被大幅稀释。对聊天、补全、生成类应用,流式几乎是必选项。
底层原理呼应《模型是怎么跑起来的》:模型生成是逐 token 自回归的,每算出一个 token 就能立刻吐出来,根本不需要等全部算完。非流式是 API 层人为”憋”出来的——它把所有 token 攒齐了再打包给你。
2.2 SSE 是什么:一条 HTTP 响应里的”小溪”
SSE(Server-Sent Events)不是新协议,就是 HTTP 上的一个约定:服务器把响应头设成 text/event-stream,然后在这条响应里持续写数据,写多久都行。它和 WebSocket 的区别,一个后端工程师应该一眼记住:
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向:服务器 → 客户端 | 双向 |
| 协议 | 纯 HTTP,一次普通请求响应 | 先 HTTP 升级再换 WS 协议 |
| 重连 | 浏览器原生支持自动重连 | 要自己写 |
| 适用 | 服务器持续推流(token、进度、日志) | 双方频繁互推(聊天室、游戏) |
LLM 的 token 流是单向的(你发了请求,模型一路回你),所以 SSE 是天然匹配的选择,用 WebSocket 属于杀鸡用牛刀。
SSE 的格式极简单。服务器写的每一”块”叫一个 event,以空行分隔;每块里可以有多行,行以 data: 开头:
data: 第一段文字
data: 第二段文字
data: [DONE]
客户端只要读这条响应流、按空行切块、剥掉 data: 前缀就行。
2.3 OpenAI 兼容 API 的流式形态
开 stream: true 后,响应头变成 text/event-stream,body 是一连串 SSE event。每个 event 的 data: 是一个 JSON 对象,里面不是全文,而是增量(delta):
data: {"id":"chatcmpl-xx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-xx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Redis"},"finish_reason":null}]}
data: {"id":"chatcmpl-xx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" 主从复制"},"finish_reason":null}]}
data: {"id":"chatcmpl-xx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
几个关键点,务必记住:
- 每个 chunk 里是
choices[0].delta.content,不是message.content——非流式是message,流式是delta,字段名都换了。 delta是增量,不是累计。前端要把每个 chunk 的content拼接起来,而不是覆盖。- 第一个 chunk 的
delta通常只有role: "assistant"、content为空;最后一个有效 chunk 的finish_reason才有值(stop/length),此时delta通常为空。 data: [DONE]不是 JSON,是流结束的哨兵。前端解析时先判断字符串是不是[DONE],别拿去JSON.parse。
还有两个容易忽略的细节:流式下 usage 默认不返回,要开 stream_options: {"include_usage": true} 才会在最后一个 chunk 里带上 token 账本(第五部分要用它算钱);流式下 finish_reason 是”最后一个 chunk 才给你”,所以判断”这次调用完没完、有没有被截断”,要在流结束时统一看。
2.4 后端怎么透传:别在中间把流”憋”住
这是后端接入工程里最容易犯的错。你从模型那边拿到一个流,要给浏览器,中间的正确动作是”透传”,不是”读完再拼”。
错误做法:把整个流读完、拼成一个 String、再一次性 return 给前端——那你就把流式又变回非流式了,TTFT 被打回原形,内存里还多缓存了一份全文。
正确做法,Spring Boot 里两套都行:
同步栈用 SseEmitter(Servlet 模型下最省事):
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String question) {
SseEmitter emitter = new SseEmitter(60_000L); // 总超时 60s
executor.submit(() -> {
try {
// 从模型拿到增量流,逐个转发,绝不缓冲全文
for (ChatChunk chunk : chatClient.stream(request(question))) {
emitter.send(SseEmitter.event()
.name("message") // 可选,事件名
.data(chunk.delta())); // 只发增量
}
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e); // 异常要显式终结,否则连接悬着
}
});
return emitter;
}
响应式栈用 WebFlux(天生就是流,更顺):
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestParam String question) {
return chatClient.streamFlux(request(question)) // 假设返回 Flux<ChatChunk>
.map(chunk -> ServerSentEvent.builder(chunk.delta()).event("message").build())
.concatWith(Flux.just(ServerSentEvent.builder("[DONE]").event("done").build()));
}
核心原则一句话:后端是这条”小溪”的中转站,你只负责把每一滴水原样往下游送,不要自己先攒成一桶。
2.5 前端怎么接:POST 不能靠 EventSource
前端有个坑:EventSource 是浏览器内置的 SSE 客户端,但它只支持 GET、不能带 body。而调模型要发一段 messages、还可能带 key,几乎都得用 POST。所以真实做法是 fetch + ReadableStream 手动解析:
const resp = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按空行切 event,剥 data: 前缀
const parts = buffer.split('\n\n');
buffer = parts.pop(); // 最后一段可能不完整,留到下次
for (const part of parts) {
const data = part.replace(/^data:\s*/, '');
if (data === '[DONE]') return;
const chunk = JSON.parse(data);
appendText(chunk.choices[0]?.delta?.content ?? '');
}
}
注意 decoder.decode(..., {stream:true}) 和那个 buffer 缓存——TCP 不会按 SSE 的 event 边界给你分块,一个网络包可能正好卡在 data: 中间,所以必须自己维护一个半成品缓冲区,这是所有”手动解析流”都要写的样板代码。
2.6 划清界限 + 生产三件套
这里要点一下本文开头说的那件事。你在MCP 那篇里看到的 SSE,是 MCP 客户端 ↔ 工具服务器之间传 JSON-RPC 消息的流(Accept: application/json, text/event-stream,内容协商决定要不要流);而这一节讲的 SSE,是 你的后端 ↔ 浏览器之间传 token 的流。两者都叫 text/event-stream,但是两个完全不同的通道、两个完全不同的目的。别把它们混成一个东西。
生产上,流式还要过三关,都是你微服务里熟的那套:
- 关掉代理缓冲:Nginx 默认会缓冲上游响应,等攒够了才发给客户端,直接把流变回了”憋”。要
proxy_buffering off;,或响应头加X-Accel-Buffering: no;。 - 拉长读超时:流式响应可能几十秒不结束,代理/网关默认 30~60 秒的读超时会把长生成掐断。超时要按”首 token 超时 + 总时长超时”分开设(第六部分细讲)。
- 心跳保活:生成间隙如果长时间没 token,中间的 LB / 代理可能以为连接死了。必要时后端定时发个注释行(SSE 里以
:开头的行是注释,客户端会忽略)当心跳。
三、Function calling:后端怎么把”工具”交给模型
3.1 先破一个最大的误解
“Function calling”这个名字极具误导性,让无数后端工程师以为”模型会调用我的函数”。不是的。 模型从头到尾只做一件事——输出文本。Function calling 的真实含义是:
你告诉模型”我手上有这些工具,长这样,参数要这样”,模型在需要的时候,输出一段”我想调用
查库存,参数是{sku: "A123"}“的文本(一个结构化 JSON);然后你自己在代码里执行这个函数,把执行结果再喂回给模型,让模型基于结果继续回答。
模型是”点菜的”,你才是”炒菜的”。它只会点单,不会做菜;点完单它停下来,等你把菜端上去,它再根据菜的味道说下一步。所以 function calling 的本质是一次”你→模型→你→模型”的多轮协作,模型在其中负责”决定调哪个、参数是什么”。
3.2 怎么把工具”介绍”给模型
请求体里多一个 tools 字段,每个工具用 JSON Schema 描述它的名字、用途、参数:
curl https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [
{"role": "user", "content": "帮我查一下 A123 这个 SKU 还有多少库存"}
],
"tools": [
{
"type": "function",
"function": {
"name": "query_stock",
"description": "查询某个 SKU 的库存数量",
"parameters": {
"type": "object",
"properties": {
"sku": {"type": "string", "description": "SKU 编号"}
},
"required": ["sku"]
}
}
}
]
}'
模型看到这句话,判断”这需要查库存,而我有 query_stock 这个工具,参数是 sku”,于是它不直接回答,而是返回:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "query_stock",
"arguments": "{\"sku\": \"A123\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
三个关键点:
finish_reason是tool_calls,不是stop——这表示”本轮没说完,我要调工具,等我”。message.tool_calls是个数组,可能一次要调多个工具(并行)。arguments是一个 JSON 字符串,不是 JSON 对象——你要自己parse。这是无数人踩的坑:看到arguments就以为是对象,直接.get("sku"),拿到的是 null。
3.3 一轮完整循环:调完工具要”回填”
模型给了 tool_calls,故事才讲了一半。你现在要:
- 取出每个
tool_calls,按name找到你的函数,parse出arguments执行。 - 把执行结果作为一个新的 message 回填到历史里,注意
role是tool,并且要带上对应的tool_call_id:
{"role": "tool", "tool_call_id": "call_abc123", "content": "{\"sku\": \"A123\", \"stock\": 42}"}
- 把这段新历史再发一次,模型看到”我点的菜端上来了”,才继续生成最终回答。
完整循环在后端就是一个 while,直到 finish_reason == "stop":
List<Message> history = new ArrayList<>(List.of(Message.user("查 A123 库存")));
while (true) {
ChatCompletion r = client.chat(history); // 带上 tools
if (r.finishReason().equals("stop")) {
return r.content(); // 正常答完,收工
}
if (r.finishReason().equals("tool_calls")) {
history.add(r.assistantMessage()); // 先把模型的 tool_calls 回填
for (ToolCall call : r.toolCalls()) {
String result = registry.execute(call.name(), call.arguments());
history.add(Message.tool(call.id(), result)); // 再把结果回填
}
continue; // 再发一轮
}
// 其它 finish_reason(length 截断等)按需处理
}
这个 while 就是 Agent 循环 的原子步——Agent 那篇讲的是”循环和目标”这套壳怎么搭,这里看到的是壳里面最底层的那一次”调工具 + 回填”。整个 Agent,不过是在这个 while 外面再套上记忆、目标、规划而已。
3.4 把 tool 当 RPC 看:注册表 + 执行边界
模型只是”点菜”,所以炒菜的全部责任都在你。这意味着函数执行这部分,要用你对待 RPC 一样的态度来设计:
public interface ToolFunction {
String name();
String description();
String parametersSchema(); // 返回 JSON Schema,用于拼 tools 数组
String execute(String argumentsJson) throws Exception;
}
- 注册表:
Map<String, ToolFunction>,按name找到实现。工具多了以后就是”服务暴露”那一套。 - 参数校验:
arguments是模型编出来的,可能漏字段、多字段、类型错。拿到手先parse再按 Schema 校验,别直接透传到你的 Service。 - 白名单与权限:模型”想调”不等于”能调”。哪些工具对哪些租户/用户开放、能查什么范围(比如库存工具只能查自己店铺的 SKU),要在执行层卡死,绝不能信模型替你守边界。
- 执行超时:工具可能查库、调下游、发邮件。每条工具要有独立超时,防止一个慢工具把整轮循环拖死。
3.5 三个常见坑
- 参数幻觉:模型可能编出不存在的参数值、漏掉必填项、或者把
sku写成"货号"。所以”解析 → 校验 → 校验不过就把错误回填给模型让它重试”是标准流程。 - 漏了回填
tool_call_id:role: tool的 message 必须带上对应的tool_call_id,否则模型不知道这个结果对应它哪一次点单,直接报 400。 - 把
arguments当对象用:再说一遍,它是字符串。先JSON.parse。
四、结构化输出:从”说人话”到”给 JSON”
4.1 你要的不是一段话,是一个 DTO
很多场景里,你让模型干活,不是要一段给人看的文字,而是要一个能直接 Jackson 反序列化进 DTO 的 JSON。比如:”把这封邮件归类 + 提取关键信息”,你期望它吐出:
{"category": "投诉", "sentiment": "negative", "order_id": "20260826-001", "keywords": ["退款", "物流"]}
然后你 objectMapper.readValue(json, EmailInfo.class) 直接入库。但如果只靠 prompt 里写一句”请返回 JSON”,你会被现实教育:模型可能给你包一层 json ` 代码围栏、可能字段名写错、可能多一句”好的,以下是结果:”、可能偶尔漏掉一个字段。自然语言模型的输出是”长得像 JSON 的话”,不是”合法的 JSON”。
4.2 三级手段,从祈祷到保证
第一级:prompt 引导(弱,只靠”求”)。
请只返回一个 JSON 对象,不要任何多余文字,不要代码围栏,字段为 category/sentiment/order_id/keywords。
管用的时候管用,不管用的时候你就得写一堆防御性解析去擦屁股。这是”生成后修复”。
第二级:response_format(强一点,协议层约束)。
请求里加 response_format,让服务端尽量保证输出是 JSON:
{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "……请返回 JSON……"}],
"response_format": { "type": "json_object" }
}
更强的做法是给 Schema:
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "email_info",
"strict": true,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string"},
"sentiment": {"type": "string"},
"order_id": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}}
},
"required": ["category", "sentiment", "order_id", "keywords"],
"additionalProperties": false
}
}
}
}
strict: true 是 OpenAI 的”严格模式”,要求 additionalProperties: false 且所有字段 required。但注意:response_format 各家支持参差不齐——有的只支持 json_object 不支持 json_schema、有的不认 strict。这是”协议层保证”,但保证的强弱因厂商而异。
第三级:约束解码(最强,采样时就保证)。
前两级本质都是”先生成、再验证/祈祷”。真正的兜底手段叫约束解码(constrained decoding),思路完全不同:模型生成下一个 token 时,本来是在整个词表上按概率采样;约束解码在采样这一步就把”会导致 JSON 非法”的 token 直接从候选里屏蔽掉,让模型只能在合法 JSON 的路径上走。
普通方式 = 模型随便说,你事后校验;约束解码 = 给模型套一个”语法牢笼”,它想说不合法的都说不出来。
所以约束解码不是”保证大概率合法”,而是数学上保证合法。落到实现,OpenAI 的 Structured Outputs、vLLM 的 guided decoding、XGrammar / Outlines 这类库,都是这一层。你本地用 vLLM 起模型时,把一个 JSON Schema 喂进去,输出就”必然是合法 JSON”。这是”生成即合法”,而不是”生成后修复”。
4.3 与 function calling 的关系:同一棵树的两根枝
讲到这里你会发现,第四部分和第三部分长得像。因为它们同源:
- function calling 里
tools[].function.parameters是 JSON Schema; - 结构化输出里
response_format.json_schema.schema也是 JSON Schema。
两者本质都是”用 JSON Schema 约束模型的输出”,区别只在约束的对象:
| 约束什么 | 模型的输出 | |
|---|---|---|
| Function calling | 约束它”选哪个工具、传什么参数” | 一个 tool_calls(结构已由协议定义) |
| 结构化输出 | 约束它”正文本身”长成什么样 | message.content 是一个合法 JSON |
理解了这层,你就不会再纠结”什么时候用 function calling、什么时候用结构化输出”:当”调一个带副作用的动作”是目的(查库、发消息、改单)用 function calling;当”拿回一份结构化的数据”是目的(抽取、分类、打分)用结构化输出。
4.4 后端落地:DTO → Schema → 校验 → 失败重试
一个可复用的套路,把你熟悉的 Jackson 和上面的手段串起来:
- 定义 DTO,用注解或工具把它转成 JSON Schema(Jackson 有
JacksonModule可生成 Schema,或用victools/jsonschema-generator)。 - 把 Schema 塞进
response_format(或tools[].parameters)。 - 拿到返回后
readValue反序列化,再做一次业务校验(约束解码只保证”合法 JSON”,不保证”业务上合理”——模型照样可能把sentiment填成"banana")。 - 校验失败就把错误回填给模型重试——这是结构化输出的隐藏闭环:模型第一遍吐的 JSON 不满足你的业务规则,你把”哪条没满足、为什么”写进 message 再问一次,它通常第二遍就对了。
for (int attempt = 0; attempt < 3; attempt++) {
String raw = chatClient.chat(historyWithSchema).content();
try {
EmailInfo info = objectMapper.readValue(raw, EmailInfo.class);
return validate(info); // 业务校验,失败抛异常
} catch (Exception e) {
history.add(Message.user("上一次输出不合法:" + e.getMessage() + ",请重新输出合法 JSON"));
}
}
throw new IllegalStateException("结构化输出重试耗尽");
这个”失败回填重试”的循环,跟第三部分 function calling 的 while 是同一个骨架——它们都是”把模型当纯函数反复调,直到输出满足约束”。
五、Token 计量与成本:钱是怎么没的
5.1 token 账本在 usage 里
每次调用,响应里都带一份 usage,这是你算钱的唯一依据:
{
"usage": {
"prompt_tokens": 42,
"completion_tokens": 318,
"total_tokens": 360,
"prompt_tokens_details": { "cached_tokens": 0 }
}
}
prompt_tokens:输入(你发过去的历史 + system + tools 描述)的 token 数。completion_tokens:输出(模型生成)的 token 数。cached_tokens:输入里命中前缀缓存的部分。很多厂商(DeepSeek、OpenAI 等)有”前缀缓存”——如果这次输入的前缀和之前某次相同,那部分输入按很低的缓存价计费甚至免费。这是省钱的隐藏通道。
关于”token 是什么、为什么同样字数中英文 token 数不一样”,去看《Token 不是字也不是词》,这里不重复,只记住:token ≠ 字,且只有 usage 里返回的数字才算数,别自己拿字数估。
5.2 计费的两个关键认知
认知一:输出比输入贵得多。 各大厂商的输出单价通常是输入的 3~10 倍。举例(仅示意量级,实际以官网为准):某模型输入 1 元/百万 token,输出可能 4 元/百万 token。这引出一个反直觉的省钱结论:
“少让模型说”比”少喂模型”更省钱。 同样省 1000 token,省在输出端值钱 3~10 倍。
认知二:max_tokens 是省钱开关。 模型不会因为你说”简短点”就真的短,它的”啰嗦程度”是采样出来的。唯一硬性约束是 max_tokens。所以:抽实体、分类这类输出长度可预期的场景,把 max_tokens 设成一个贴紧预期的值,直接锁死上限——既省钱又防”模型一时兴起写篇小作文”。
5.3 计量:把每次调用的钱记下来
钱要算得清,就得在每一次调用后立刻把 usage 落进日志或埋点,而不是事后算。一个最小可用的做法:
ChatCompletion r = client.chat(request);
usageLog.record(
UsageRecord.builder()
.requestId(traceId()) // 关联到你已有的分布式 trace
.model(request.model())
.promptTokens(r.usage().promptTokens())
.completionTokens(r.usage().completionTokens())
.cachedTokens(r.usage().cachedTokens())
.estimatedCost(costOf(request.model(), r.usage()))
.build()
);
然后按 model、按接口、按租户、按用户聚合,就能回答那些你迟早被问的问题:”这个功能上线后,一天烧了多少钱?”“哪个租户把额度打爆了?”“哪个模型性价比最高?”——这跟《Agent 可观测性》里”把 LLM 调用接进你的 trace”是同一件事,token 账本只是观测里最基础的一张表。
注意:流式下 usage 默认不返回,记得开 stream_options: {"include_usage": true}(见 2.3),否则你流式跑了一整天却一笔账都记不上。
5.4 成本工程的四个杠杆
按”省钱的杠杆从大到小”排:
- 前缀缓存:把稳定不变的部分(system prompt、工具描述、文档上下文)放在 messages 的最前面且保持逐字节一致,命中前缀缓存后这部分输入近乎免费。代价是这些内容不能随便改——改了缓存就失效。很多框架里那个”系统提示词放最前、可变内容放最后”的约定,不是为了整洁,是为了蹭缓存。
- 裁剪上下文:长对话的 token 是滚雪球(1.3 节),所以要主动裁剪/摘要历史,而不是无限 append。这是”用输出换输入省钱”的反向操作——摘要要花一点输出 token,但省下大把输入 token。
- 模型分级路由:不是所有请求都值得上最贵的模型。抽实体、分类、改写这些”简单活”路由到便宜的小模型,只有复杂推理才上大模型。这就是你微服务里”核心链路 vs 非核心链路”的分流。
- batch:对时效不敏感的批量任务(离线标注、批量总结)走 batch API,通常有折扣。
六、超时 · 重试 · 降级 · 幂等:把它当普通下游服务
最后这一部分,其实是全文的题眼。很多后端工程师一碰到大模型就”降智”,把它当成什么神秘存在。不,它就是一个下游服务——而且是一个特别差的下游:慢(秒级到分钟级)、贵(每次调用都计费)、不稳定(会 429、会超时、会抽风)、且输出不确定(同一个输入每次结果不一样)。你应对下游服务的那套稳性武器,一件不少全用得上。
6.1 超时:要分两层设
普通下游服务一个”读超时”就够了,LLM 不行,因为它有”首 token”和”总时长”两个完全不同的时间尺度:
| 超时 | 含义 | 典型值 | 目的 |
|---|---|---|---|
| 连接超时 | TCP 建连多久没成功 | 3~10s | 快速失败,别傻等 |
| 首 token 超时(TTFT 超时) | 第一个 token 多久没到 | 10~30s | 判断模型是不是”卡死/排队”了 |
| 总时长超时 | 整个生成多久必须结束 | 按任务定,60s~几分钟 | 防止长生成无休止 |
千万别用一个 3 秒的全局读超时去套长生成——你会把所有”需要思考 5 秒以上”的请求全部杀死。反过来,也别把总时长设成无限——一个失控的长生成会占着连接、烧着钱、拖着上游。合理姿势:首 token 超时定得紧(没动静就是有问题),总时长超时定得宽但必须有。
流式场景下,这两个超时还要落到”流”上:从发请求到收到第一个 chunk 是首 token 超时;两个 chunk 之间的间隔、以及整体结束时间,是总时长超时。 中间代理/网关的读超时也要相应放宽(2.6 已提)。
6.2 重试:先分清”能不能重”
重试的第一原则:只重试幂等的失败,且要退避。
LLM 调用里,哪些失败能安全重试?
- 429(限流):明确可重试,但要读响应头
Retry-After,别立刻莽。指数退避 + 抖动。 - 5xx / 网络超时 / 连接重置:请求可能根本没到模型、或没开始生成,重试通常安全。
- 已经生成了输出、但传输中断:慎重重试。这次生成可能已经计费了,重试=再花一遍钱。
哪些不能盲目重试?
- 带副作用的工具调用(3.3 的 while 里,如果工具是”扣款”、”发邮件”,模型给出 tool_calls 后你执行了,结果回传失败——这时候不能简单重试整个对话,否则工具可能执行两遍)。这是 function calling + 重试组合里最隐蔽的坑。
- 400 参数错误:重试一百遍也没用,是代码 bug。
一个朴素的退避实现(你就是写个带指数的 sleep,不需要上重型框架):
public <T> T callWithRetry(Supplier<T> call, int maxAttempts) {
long backoff = 100; // 初始 100ms
for (int attempt = 1; ; attempt++) {
try {
return call.get();
} catch (RateLimitException e) {
if (attempt >= maxAttempts) throw e;
sleep(Math.min(backoff * (1L << (attempt - 1)) + jitter(), 30_000));
// 429 优先读 Retry-After,其次才用指数退避
} catch (TransientException e) {
if (attempt >= maxAttempts) throw e;
sleep(Math.min(backoff * (1L << (attempt - 1)) + jitter(), 30_000));
}
// 4xx 参数错误不重试,直接抛
}
}
记住一条账:重试不是免费的。每多试一次,就多花一次钱,还可能多执行一次工具。所以重试次数要设上限(2~3 次),并且重试的语义要明确到”这次失败里,到底有没有产生副作用/有没有计费”。
6.3 降级 + 熔断:别让模型拖垮你
当主模型连续失败(连续超时、连续 5xx),你要有熔断——就像你对一个下游 DB 集群做的那样:连续 N 次失败 → 打开熔断器 → 快速失败,不再把请求往一个已经证明死了的模型上砸。
熔断之后是降级,按成本从低到高可以:
- 切备用模型:主模型(贵/强)挂了,切到备用模型(便宜/弱),牺牲质量保可用。
- 切本地/缓存:对高频重复问题(FAQ),直接查缓存返回,不调模型。
- 给默认答案 / 排队:都不行就返回”服务繁忙,请稍后重试”,或把请求丢进队列异步处理。
这套东西就是 Resilience4j / Sentinel 那套,换个名字而已。LLM 是这个系统里”最不稳定 + 最贵”的一环,所以它反而最该被熔断和降级保护——不是保护模型,是保护你的服务和你的钱包。
6.4 幂等:LLM 天生不幂等
这是 LLM 和普通下游最大的区别,也是”幂等”这个词在这里最要命的地方:
普通 RPC 幂等,是为了”重试不产生副作用”;LLM 不幂等,是因为”同一个输入,每次输出都不同,且每次都计费”。
你给同一个 prompt,模型两遍的答案措辞不同、可能结论也不同,而且两次都按全价计费。所以”重试”在 LLM 上不是免费的——它花的是真金白银,还引入输出不一致。
那幂等怎么做?靠业务层,不靠模型层。
- 幂等键:给每个业务请求一个幂等键(
requestId),相同幂等键的请求命中缓存就直接返回上次的结果,不再调模型。这在”重放”(客户端超时重发、消息队列重投)场景下尤其重要——用户点了一下没响应又点一下,你不该为这两下各付一次钱。 - 结果缓存:把”输入(model + messages + 参数)→ 输出”的映射缓存起来,命中就返回。前提是同样的输入你愿意接受同样的输出(FAQ、确定性抽取这类场景天然适合;创意类不适合)。
String cacheKey = hash(model, messages, params);
Cached cached = cache.get(cacheKey);
if (cached != null) return cached.output(); // 幂等:命中缓存,不调模型
ChatCompletion r = client.chat(request);
cache.put(cacheKey, r, ttl); // 落缓存,供后续重放命中
return r.content();
心态上的总结:对 LLM,”重放”的代价不是”数据错乱”,而是“多花一笔钱”。所以 LLM 应用里的幂等设计,本质是成本控制的一部分,而不只是数据一致性。
6.5 兜底:把这些编成一层横切
看到这里你会意识到,这一整部分讲的都是横切关注点,不该散落在每个调用点。正确做法是像 Dubbo 的 Filter / Spring 的 AOP 一样,把”超时 + 重试 + 熔断 + 计量 + 日志”编成一个统一的 LLMClient 包装层:
业务代码 → LLMClient 门面
├─ 超时策略(连接 / 首 token / 总时长)
├─ 重试策略(指数退避 + 429 读 Retry-After)
├─ 熔断器(连续失败快速失败)
├─ 降级链(备模型 → 缓存 → 默认答案)
├─ 计量埋点(usage → 成本 → trace)
└─ 幂等缓存(输入哈希 → 命中返回)
你的业务代码只调 client.chat(request),剩下的稳性手段全在门面里。这就是把”接入工程”沉淀成”基础设施”的那一步——跟你当年把 Redis、DB、MQ 的访问抽成 client 包、把重试熔断做成 starter,是同一件事。
结尾:先写对一次调用,再谈工具标准
回到开头那个问题:MCP 讲”工具标准”,Agent 讲”循环与目标”,但你后端里那一次最朴素的调用,才是所有这些的地基。把这六件事串起来看,你会发现它们其实是一张递进的地图:
| 阶段 | 你在做的事 | 一句话 |
|---|---|---|
| 协议(一) | 用 OpenAI 兼容 API 把模型调用起来,并抽象成接口 | 先能”说上话” |
| 流式(二) | 用 SSE 把 token 一路透传到浏览器 | 再能”流得动” |
| 工具(三) | 用 function calling 让模型点菜、你来炒菜 | 让它”能办事” |
| 结构(四) | 用 Schema / 约束解码让输出变成合法 JSON | 让它”交得出” |
| 计量(五) | 用 usage 把每一分钱记下来 | 让它”花得明” |
| 稳性(六) | 用超时/重试/降级/幂等扛住真实流量 | 让它”扛得住” |
前两步是”通”,中间两步是”用”,最后两步是”活”。而无论 MCP 还是 Agent,本质都是站在第六层(一个稳的、可计量的、可降级的 LLM 调用)之上,再去谈”工具怎么标准化”“任务怎么编排”。
对一个重度使用 AI 的微服务后端工程师来说,这六件事不是”进阶知识”,是每天的日常代码。它们不难,难的是有人把这一层从 Agent 和 MCP 的宏大叙事里单独拎出来,讲清楚”一次调用而已,为什么要这么多讲究”。
下一篇如果接着往下写,就该是把这个 LLMClient 门面接到 Agent 循环上、接到 MCP 工具服务器上,看这六件基本功怎么被”编排层”消费了——但那是另一个故事。先把这一次调用写对。
延伸阅读:
- 《大模型无状态》 —— 为什么多轮要靠重发历史
- 《Token 不是字也不是词》 —— 计量里”token”到底是什么
- 《Agent 的循环与目标》 —— function calling 之上那层循环怎么转
- 《MCP 全面解析》 —— SSE 在”工具协议”里的另一种用法
- 《Agent 可观测性》 —— token 计量怎么接进分布式 trace