MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

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-chatgpt-4oqwen-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 实现,再按需加 DeepSeekClientQwenClient…… 这个套路对你来说就是换个 SPI 实现的事,跟 Dubbo 里换注册中心、换序列化一个道理。

先定接口、后接厂商。 别让业务代码里散落 deepseek-chatgpt-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]

几个关键点,务必记住:

  1. 每个 chunk 里是 choices[0].delta.content,不是 message.content——非流式是 message,流式是 delta,字段名都换了。
  2. delta 是增量,不是累计。前端要把每个 chunk 的 content 拼接起来,而不是覆盖。
  3. 第一个 chunk 的 delta 通常只有 role: "assistant"content 为空;最后一个有效 chunk 的 finish_reason 才有值(stop/length),此时 delta 通常为空。
  4. 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,但是两个完全不同的通道、两个完全不同的目的。别把它们混成一个东西。

生产上,流式还要过三关,都是你微服务里熟的那套:

  1. 关掉代理缓冲:Nginx 默认会缓冲上游响应,等攒够了才发给客户端,直接把流变回了”憋”。要 proxy_buffering off;,或响应头加 X-Accel-Buffering: no;
  2. 拉长读超时:流式响应可能几十秒不结束,代理/网关默认 30~60 秒的读超时会把长生成掐断。超时要按”首 token 超时 + 总时长超时”分开设(第六部分细讲)。
  3. 心跳保活:生成间隙如果长时间没 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"
    }
  ]
}

三个关键点:

  1. finish_reasontool_calls,不是 stop——这表示”本轮没说完,我要调工具,等我”。
  2. message.tool_calls 是个数组,可能一次要调多个工具(并行)。
  3. arguments 是一个 JSON 字符串,不是 JSON 对象——你要自己 parse。这是无数人踩的坑:看到 arguments 就以为是对象,直接 .get("sku"),拿到的是 null。

3.3 一轮完整循环:调完工具要”回填”

模型给了 tool_calls,故事才讲了一半。你现在要:

  1. 取出每个 tool_calls,按 name 找到你的函数,parsearguments 执行。
  2. 把执行结果作为一个新的 message 回填到历史里,注意 roletool,并且要带上对应的 tool_call_id
{"role": "tool", "tool_call_id": "call_abc123", "content": "{\"sku\": \"A123\", \"stock\": 42}"}
  1. 把这段新历史再发一次,模型看到”我点的菜端上来了”,才继续生成最终回答。

完整循环在后端就是一个 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 三个常见坑

  1. 参数幻觉:模型可能编出不存在的参数值、漏掉必填项、或者把 sku 写成 "货号"。所以”解析 → 校验 → 校验不过就把错误回填给模型让它重试”是标准流程。
  2. 漏了回填 tool_call_idrole: tool 的 message 必须带上对应的 tool_call_id,否则模型不知道这个结果对应它哪一次点单,直接报 400。
  3. 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 和上面的手段串起来:

  1. 定义 DTO,用注解或工具把它转成 JSON Schema(Jackson 有 JacksonModule 可生成 Schema,或用 victools/jsonschema-generator)。
  2. 把 Schema 塞进 response_format(或 tools[].parameters)。
  3. 拿到返回后 readValue 反序列化,再做一次业务校验(约束解码只保证”合法 JSON”,不保证”业务上合理”——模型照样可能把 sentiment 填成 "banana")。
  4. 校验失败就把错误回填给模型重试——这是结构化输出的隐藏闭环:模型第一遍吐的 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 成本工程的四个杠杆

按”省钱的杠杆从大到小”排:

  1. 前缀缓存:把稳定不变的部分(system prompt、工具描述、文档上下文)放在 messages 的最前面保持逐字节一致,命中前缀缓存后这部分输入近乎免费。代价是这些内容不能随便改——改了缓存就失效。很多框架里那个”系统提示词放最前、可变内容放最后”的约定,不是为了整洁,是为了蹭缓存
  2. 裁剪上下文:长对话的 token 是滚雪球(1.3 节),所以要主动裁剪/摘要历史,而不是无限 append。这是”用输出换输入省钱”的反向操作——摘要要花一点输出 token,但省下大把输入 token。
  3. 模型分级路由:不是所有请求都值得上最贵的模型。抽实体、分类、改写这些”简单活”路由到便宜的小模型,只有复杂推理才上大模型。这就是你微服务里”核心链路 vs 非核心链路”的分流。
  4. 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 次失败 → 打开熔断器 → 快速失败,不再把请求往一个已经证明死了的模型上砸。

熔断之后是降级,按成本从低到高可以:

  1. 切备用模型:主模型(贵/强)挂了,切到备用模型(便宜/弱),牺牲质量保可用。
  2. 切本地/缓存:对高频重复问题(FAQ),直接查缓存返回,不调模型。
  3. 给默认答案 / 排队:都不行就返回”服务繁忙,请稍后重试”,或把请求丢进队列异步处理。

这套东西就是 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 工具服务器上,看这六件基本功怎么被”编排层”消费了——但那是另一个故事。先把这一次调用写对。


延伸阅读:

本文阅读量