最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
AI 大模型应用常见问题与工程化解决思路
时间:2026-09-13 10:26:01 编辑:袖梨 来源:一聚教程网
大模型接入业务并不等于应用已经具备生产能力。模型输出存在非确定性,外部调用又受到延迟、成本和服务稳定性的影响,任何一个环节缺少约束都可能放大故障。下面将从工具调用、RAG、生成稳定性和工程治理等方面,逐步分析常见问题及对应的落地方案。
面向生产级进阶工程师的实战手册:把 LLM 当生产组件时,你会踩到的坑、怎么排查、怎么兜底,以及这套方法论如何落到 Spring AI / Spring AI Alibaba 上。
版本基线声明
本文所有代码示例基于以下版本,读者照抄前请先对齐:
| 组件 | 版本 |
|---|---|
| Spring AI | 1.1.2 |
| Spring AI Alibaba | 1.1.2.2 |
| Spring Boot | 3.5.9 |
| JDK | 21+ |
⚠️ 提示:Spring AI 在 1.x → 2.0 之间对工具调用与 RAG Advisor 做了较大重构(详见第 1、2 章标注)。本文以 1.1.x 为准,2.0 的差异点用提示框单独标注。文中 API 名请以 1.1.2 官方文档做最终核对。
0. 前言:一张"问题地图"
大模型应用在 demo 阶段往往很惊艳,一上生产就暴露出各种问题。原因在于:LLM 是一个非确定性的、有状态的、有延迟和成本的远程服务,它既不是普通数据库,也不是普通 HTTP 接口。把它的特性不当回事,就会在工具调用、检索、稳定性、模型能力四个维度反复踩坑。
下面是典型链路的"问题地图",每个环节都标注了高频故障点:
用户输入
│
▼
① 意图识别 / 工具调用 ── 选错工具、参数幻觉、不调用、死循环
│
▼
② 检索(RAG) ── 匹配度低、内容杂乱、漏召、文档解析差
│
▼
③ 生成 ── 幻觉、格式不稳定、超时、限流、中途失败
│
▼
④ 输出校验/结构化 ── JSON 非法、字段缺失、引用错误
│
▼
返回
全文按链路环节 + 主题双轨组织:
- 第 1 章 Function Calling、第 2 章 RAG、第 3 章稳定性与容量、第 4 章微调 对应上图的四个环节;
- 第 5 章 是横切的工程化治理主题(Prompt 治理、模型漂移、多轮对话、安全等)。
贯穿全文的两条横切约定,读者需要记住:
- 同步 vs 流式:凡是涉及工具调用、超时、重试、错误处理、结构化输出的地方,
.call()(同步)和.stream()(流式)语义有本质区别——流式一旦开始下发 token,"失败"和"重试"的含义就变了(详见第 1、3 章)。 - 评测(Eval):任何优化都应该是"先测基线、改后看增益"。每个主题末尾都附一句"如何评测",第 5.3 节统一汇总。
1. Function Calling 核心逻辑与生产问题
1.1 核心逻辑:模型到底如何"精准匹配"工具
先破除一个常见误解:模型并不会"调用"你的工具,它只输出一个结构化的"调用意图"——一个工具名加一串参数 JSON。
system + tools(定义) + user
│
▼
模型输出 tool_call { name: "get_weather", arguments: { "city": "杭州" } }
│
▼
【应用侧】真正执行 get_weather("杭州") ← 执行权在你手里,不在模型手里
│
▼
把执行结果回填进消息,模型续写最终答案
"执行权在应用侧"是整个 Function Calling 认知的地基。它意味着:模型只能"请求",不能"执行",所有副作用(查库、调外部 API、发消息)都由你的代码完成,因此也由你的代码负责校验、鉴权和兜底。
那么模型为什么有时"选得准"、有时"选得离谱"?"精准匹配"由四件事共同决定:
- 工具
description的质量——不仅要写"做什么",更要写"什么时候该用、什么时候不该用"; - 参数
JSON Schema的质量——required、enum、类型、参数描述是否清晰; - 命名清晰度——工具名和参数名是否无歧义、不重叠;
- 提示词里的引导——是否给出了调用时机、约束和反例。
1.2 生产常见问题 → 解决方案
先给一张总览表,下面 1.3 逐个展开——每个问题都附具体的"现象"例子和对应的 Spring AI 落地代码:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 选错工具 / 该调用却不调用 | 描述模糊、语义重叠、缺"何时用"指引 | 重写 description 为"做什么+何时用";拆分重叠工具;few-shot 示例 |
| 参数幻觉 / 缺失必填项 | schema 约束不清、无示例、枚举未闭合 | 补 required/enum/参数描述;应用侧参数校验与二次补全 |
| 多工具混淆 | 功能边界重叠 | 合并为单工具+枚举参数,或加"选择准则"说明 |
| 工具结果过大撑爆上下文 | 工具返回未裁剪 | 结果裁剪/摘要、只保留关键字段、分页 |
| 多步/并行调用编排失控 | 未设调用次数与依赖控制 | 设 max_iterations、并行调用合并、结果聚合 |
| 工具互相调用导致死循环 | 缺少终止条件 | 次数上限 + 终止标记 + 超时兜底 |
1.3 六类问题逐一拆解
① 选错工具 / 该调用却不调用
- 现象:用户问"帮我看看明天杭州会不会下雨",结果模型没调
get_weather,直接编了一个"杭州明天晴转多云,25℃"。更糟的是系统里还挂着search_stock(查),模型把"天气"听岔了,调了search_stock,回了一串代码。 - 根因:工具
description只写了"查询天气"四个字,没告诉模型"什么时候该用、什么时候不该用";get_weather和search_stock的描述都含糊,模型只能靠猜。 - 排查:同一个 query 固定
temperature=0连跑 20 次,统计每次调了哪个工具;命中分散、时对时错,基本是工具定义质量问题,而不是模型能力问题。 - 方案:description 重写为"做什么 + 何时用 + 何时不用"三要素;拆开语义重叠的工具;补 few-shot 示例。
// ① 对应落地:description 三要素,直接决定"选得准不准"
@Tool(description = "查询指定城市当天天气。当用户询问某地天气/气温/是否下雨时调用;当用户只问穿衣建议、或未提及具体城市时不要调用。")
public String getWeather(@ToolParam(description = "城市名,如'杭州'") String city) {
return weatherService.fetch(city);
}
- 生产注意点:few-shot 是提高稳定性的最廉价手段,但示例要放进工具描述而非仅靠 system prompt,否则工具增多后仍然漂移。
② 参数幻觉 / 缺失必填项
- 现象:工具
get_order声明order_id必填,但模型调用时返回{"order_id": null};或者更隐蔽——用户根本没提供单号,模型却"自信地"填了个order_id=88888888,你的代码真拿这个值去查库,返回一条错误订单。 - 根因:schema 没标
required、没给enum、参数描述没说清"这个值该从哪来",模型就自由发挥。 - 方案:补齐 schema(
required/enum);应用侧做参数校验——不要信任模型给的参数,校验不通过就返回提示让模型去反问用户,而不是硬执行。
// ② 对应落地:方法体里做参数校验,不信任模型给的参数
@Tool(description = "按订单号查询订单。仅当用户明确给出订单号时调用。")
public String getOrder(@ToolParam(description = "订单号,必须来自用户原话,不可猜测") String orderId) {
if (orderId == null || orderId.isBlank()) { // 应用侧校验
return "缺少订单号,请向用户询问订单号后再查"; // 返回提示,引导模型反问
}
Order order = orderService.findById(orderId);
return order != null ? order.toString() : "未找到该订单,请向用户确认订单号是否正确";
}
- 生产注意点:校验逻辑放应用侧(不是模型侧),这是唯一可靠的兜底;对高危操作(、删除)必须二次确认。
③ 多工具混淆
- 现象:系统里有
search_news(新闻)和search_docs(内部文档)两个工具,用户问"查一下最近公司有没有关于绩效考核的通知",模型一会儿调 news、一会儿调 docs,甚至两个都调然后把结果拼错。你改了好几版 description 还是不稳。 - 根因:两个工具功能边界重叠,description 再怎么改都难做到"互斥"。
- 方案:优先合并为单工具 + 枚举参数(
search(source=NEWS|DOCS)),让模型只需选参数、不必在两个工具之间二选一。
// ③ 对应落地:合并为单工具 + 枚举参数,收窄模型的选择空间
public enum SearchSource { NEWS, DOCS }
@Tool(description = "搜索内容。当用户想查找某主题的信息时调用。")
public String search(@ToolParam(description = "关键词") String keyword,
@ToolParam(description = "来源:NEWS=新闻,DOCS=内部文档") SearchSource source) {
return source == SearchSource.NEWS ? newsService.search(keyword) : docService.search(keyword);
}
④ 工具结果过大撑爆上下文
- 现象:
get_logs工具一次返回 2 万行日志,回填给模型后上下文直接爆掉——模型开始"前言不搭后语",token 也暴涨;get_doc则返回了整篇 5 万字的文档。 - 根因:工具返回未裁剪,把"查询"和"精读全文"混在了一次调用里。
- 方案:工具层就做裁剪/摘要——只返回关键字段、截断超长内容、分页返回,把"要不要看全文"留给下一轮对话。
// ④ 对应落地:在工具方法内部裁剪,别把原始大结果直接回填
@Tool(description = "查询系统日志。返回最近的关键日志摘要。")
public String getLogs(@ToolParam(description = "服务名") String service) {
List<Log> logs = logService.query(service, 1000); // 最多取 1000 条
String summary = LogSummarizer.summarize(logs, 20); // 只返回 20 条关键摘要 + 统计
return summary + "n(共 " + logs.size() + " 条,如需明细可继续查询)";
}
⑤ 多步/并行调用编排失控
- 现象:用户问"帮我对比 A、B 两个产品的价格和评价",本该并行调
get_price(A)、get_price(B)、get_reviews(A)、get_reviews(B)四个互不依赖的调用,结果模型一轮一轮串行调,一次请求跑了 30 多轮、一分多钟才返回。 - 根因:没有调用次数上限,也没有对"独立调用并行化"的处理。
- 方案:设置
max_iterations(如 5~10);能并行的调用合并一次下发、并发执行;有依赖的串行执行 + 结果聚合。
// ⑤ 对应落地:toolCalls 从哪来 + 如何并行执行
// 先说结论:默认 ChatClient.call() 由 DefaultToolCallingManager 顺序执行完整个循环,
// 你拿不到中间的 toolCalls,也不需要拿。只有要"并行/自定义编排"时才手动驱动。
Map<String, ToolCallback> callbacks = buildCallbacks(); // @Tool 方法 → 按工具名索引的 ToolCallback
ChatResponse response = [email protected](prompt); // ① 直接调 ch@tModel,不自动执行工具
AssistantMessage output = response.getResult().getOutput();
if (output.hasToolCalls()) {
List<ToolCall> toolCalls = output.getToolCalls(); // ② ← toolCalls 的来源在这里
// ③ 并行执行所有 tool_call(ToolCall 只有 name + arguments)
List<CompletableFuture<String>> futures = toolCalls.stream()
.map(tc -> CompletableFuture.supplyAsync(() ->
callbacks.get(tc.name()).call(tc.arguments())))
.toList();
List<String> results = futures.stream().map(CompletableFuture::join).toList();
// ④ 把结果包装成 tool message 拼回历史,再调模型进入下一轮循环
// (顺序执行时这一步由 ToolCallingManager.executeToolCalls() 替你完成;
// 并行执行需自己按 tool_call.id 组装 ToolResponseMessage)
}
说明:
buildCallbacks():用MethodToolCallbackProvider把@Tool方法包装成ToolCallback,再按getToolDefinition().name()建立Map<String, ToolCallback>。- 默认模式(推荐 90% 场景):
.call()自动把工具循环跑完,无需关心 toolCalls。- 手动模式(要并行/自定义):直接
[email protected](prompt),从response.getResult().getOutput().getToolCalls()取List<ToolCall>;每个ToolCall只有name()和arguments(),用name()查ToolCallback、传arguments()执行。- Spring AI 1.x 默认顺序执行;并行只能手动
CompletableFuture(或升级 2.0 的异步工具调用,Issue #4755)。调用次数上限见 ⑥。
⑥ 工具互相调用导致死循环
- 现象:工具 A 返回的内容让模型又调了工具 B,B 返回的内容又触发 A,循环往复。用户永远等不到答案,直到你的服务超时、token 打满、爆炸。
- 根因:缺少终止条件。
- 方案:三层兜底——调用次数上限(硬终止)、终止标记(检测到重复工具+参数组合即停)、超时兜底。
# 示意:带终止条件的工具执行循环(通用思路)
max_iterations = 8
seen = set()
for i in range(max_iterations):
call = model.generate(messages)
if not call.has_tool_call:
return call.text
key = (call.name, json(call.arguments))
if key in seen: # 检测到循环
return fallback("检测到重复调用,终止并返回提示")
seen.add(key)
result = execute(call)
messages.append(tool_result(result))
return fallback("超出最大调用次数")
// ⑥ 对应落地:Spring AI 已内置调用次数上限——
// DefaultToolCallingManager 默认 per-tool 40 次、整轮总调用 150 次,默认即可挡住绝大多数死循环。
// 如需更严格,可自定义 ToolCallingManager 在其 builder 上调低上限。
// "重复工具+参数即停"的终止标记默认不内置,需在工具侧自行实现(对应上方伪代码的 seen 逻辑)。
1.4 Spring AI 通用机制:一次工具调用到底发生了什么
1.3 讲的是"每类问题怎么写工具",这一节讲"工具调用的底层怎么运转"——理解这个,前面很多做法(校验、裁剪、兜底)就顺理成章了。
从一个最小例子讲起:
@Tool(description = "查询指定城市当天天气")
public String getWeather(@ToolParam(description = "城市名") String city) {
return weatherService.fetch(city); // 你只写了这一行真正的业务逻辑
}
String answer = ChatClient.builder(ch@tModel)
.defaultTools(new WeatherTools())
.build()
.prompt()
.user("杭州天气怎么样?")
.call()
.content();
你只写了 getWeather 一个方法,剩下整轮循环都是 Spring AI 替你做的。背后实际发生的是:
第 1 轮:把「getWeather 的定义」+「用户问题」一起发给模型
→ 模型不直接回答,返回一个 tool_call:{"name":"getWeather","arguments":"{"city":"杭州"}"}
第 2 步:ToolCallingManager 拿到 tool_call,按 name 找到你的 getWeather(已包装成 ToolCallback),
把 arguments 转成入参,真正执行 weatherService.fetch("杭州") ← 执行权在应用侧
第 3 轮:把执行结果("杭州 25℃,晴")作为 tool message 回填,连同历史再次发给模型
→ 模型给出最终回答:"杭州今天晴,25℃"
这一流程里的三个组件各司其职:
| 组件 | 职责 |
|---|---|
@Tool 方法 → ToolCallback | 一半是"给模型看的定义"(name/description/schema),一半是"给应用执行的方法" |
ToolCallingManager(默认 DefaultToolCallingManager) | 上面的"调度器":发请求 → 收 tool_call → 执行 → 回填 → 再发请求,直到模型不再要工具 |
ToolExecutionExceptionProcessor | 工具方法抛异常时,决定"把异常回传给模型让它解释"还是"直接抛给调用方" |
工具方法抛异常怎么办:由 ToolExecutionExceptionProcessor(默认 DefaultToolExecutionExceptionProcessor)统一处理,alwaysThrow 决定走哪条路:
- 回传给模型(默认,
alwaysThrow=false):把异常信息转成字符串回填给模型,模型看到错误后会换种方式重试或如实告知用户。适合"可恢复"的错误(参数不对、资源暂时不可用等)。 - 直接抛出(
alwaysThrow=true):异常中断整次调用,由你的代码兜底(记日志、返回降级答案等)。适合"不想让模型看到内部错误细节"或"必须让调用方感知"的场景。
// 方式一:自定义 Bean,全局改成"直接抛出"
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(true); // true = 直接抛出
}
// 方式二:只对特定异常直接抛出,其余回传给模型
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(
false, // 默认回传模型
List.of(PermissionDeniedException.class) // 白名单:这些异常直接抛出
);
}
# 方式三:Spring Boot 属性配置(最简单)
spring.ai.tools.throw-exception-on-error=true # true=直接抛出;false(默认)=回传模型
注意:默认只对
RuntimeException的 message 做"回传模型"处理;受检异常和Error(如IOException、OutOfMemoryError)无论如何都会直接抛出,不经过回传。
ToolContext:给工具传"模型不该知道"的数据:
@Tool(description = "查询当前用户的订单")
public String getMyOrders(ToolContext context) {
String tenantId = (String) context.getContext().get("tenantId"); // 应用侧注入
String userId = (String) context.getContext().get("userId");
return orderService.find(tenantId, userId); // 模型看不到这些值,也无法伪造
}
租户 ID、用户 ID 这类数据不该写进工具描述或 prompt——否则模型能看到、甚至被诱导串租户;而是通过 ToolContext 在调用时由应用侧注入,模型全程无感知。
同步 vs 流式:
- 同步
.call():上面的循环整轮跑完才返回,异常直接抛、可整体重试。 - 流式
.stream():返回Flux<ChatResponse>,模型逐字输出。普通文本"来一段显示一段"即可,但工具调用的参数是 JSON,流式下它会分片到达——比如{"city":"杭州","date":"明天"}可能先到{"city":"杭、再到州","date":"明、再到天"}。谁在碎片还碎着时就急着解析/执行,谁就拿到"半截参数"(不完整的 JSON)。1.x 流式工具调用的自动聚合不完善,所以生产上要么改用同步封装(.call()内部攒齐了才返回,绕开分片),要么走 user-controlled 模式自己攒分片 → 解析 → 执行 → 续流。
MCP 工具生态:Spring AI 支持 MCP(Model Context Protocol),通过 McpToolCallback 接入外部 MCP Server 暴露的工具,统一工具来源、鉴权与生命周期,避免各工具来源碎片化。
1.5 评测
构建一个小型标注集(query → 期望调用的工具 + 期望参数),用工具选择准确率和参数正确率两个指标度量。任何工具定义的改动都跑一遍这个集,防止"改好了一个、改坏了三个"。
2. RAG:检索质量问题的排查与优化
2.1 排查路径:按链路逐层定位
RAG 出问题,最忌讳"上来就换 embedding 模型"。先按链路逐层定位,确定是哪一层出的问题:
数据解析 → 切分 → 索引 → Query → 查询增强 → 召回(向量/关键词) → 重排序 → 生成
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └─ 生成为什么没用上
│ │ │ │ │ │ └─ 重排有没有帮倒忙
│ │ │ │ │ └─ 召回阶段有没有召到
│ │ │ │ └─ query 本身有没有写清楚
│ │ │ └─ 索引建对了吗
│ │ └─ 切分颗粒度对吗
│ └─ 文档解析干净吗
└─ 原始数据本身质量够吗
判断"哪层出问题"的通用手法:手动取回 top-k,人工看相关性;算召回率/命中率;对比 embedding 模型在你自己的语料上的表现。先定位再优化,否则就是盲改。
2.2 RAG 常见问题 → 解决方案
先给一张总览表,下面 2.3 逐个展开——每个问题都附具体的"现象"例子和对应的 Spring AI 落地代码:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 召回命中率低(该召到的没召到) | 切分/embedding/查询增强不当 | 对齐切分、换适配 embedding、查询改写 |
| 检索内容杂乱 | 切分不合理、索引脏、无去重 | 对齐切分、去重、混合检索、重排 |
| 文档解析质量差 | 扫描件/表格/乱码 | 按类型选解析器、OCR、清洗 |
| 口语化 query 召不准 | 口语与文档用词不一致 | 查询改写 / 扩展 / HyDE |
| 漏召精确实体 | 纯向量召回 | BM25 混合 + RRF |
| 粗排噪声多 | 只用 embedding 相似度 | rerank 精排 |
| 查不到就瞎编 | 空上下文未处理 | allowEmptyContext(false) |
2.3 七类问题逐一拆解
① 文档解析质量差(数据前置)
- 现象:知识库里 300 份 PDF,一半是扫描件、另一半带复杂表格。用户问"这个季度华南区的销售额",召回的全是乱码和错行片段,永远"够不到"表格里的数字。
- 根因:检索质量的上限由解析质量决定,解析烂,后面全白搭。
- 排查:抽样人工比对"解析结果 vs 原文",看表格结构、标题层级是否丢失。
- 方案:按文档类型选解析器(文本型 PDF 直接抽、扫描件走 OCR、表格保留结构);解析后清洗(去页眉页脚、去乱码、合并断行)。
// ① 对应落地:按类型选 reader 读取 → 切分 → 入库
DocumentReader reader = new PagePdfDocumentReader(new FileSystemResource("docs/sales.pdf"));
// Word/HTML 用 TikaDocumentReader;扫描件先走 OCR
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(600) // 按自身语料标定,过碎/过大都会漏召
.withKeepSeparator(true)
.build();
List<Document> docs = splitter.apply(reader.get());
vectorStore.add(docs);
- 生产注意点:解析质量要进评测——对每类文档各抽 N 条做解析准确率基线,新文档类型入库前先验证。
② 匹配度低:该召到的没召到
- 现象:用户问"怎么配置告警阈值",知识库里明明有这段文档,但 top-k 结果里没有它,模型只能瞎答。
- 根因:可能是"召回阶段没召到",也可能是"召到了但生成没用上"——两者排查方向完全不同。
- 排查:先手动取 top-k 看有没有相关内容。
- 有内容但生成没用上 → 问题在生成/提示词,不在检索;
- 没有内容 → 问题在召回,继续往下查 embedding、切分、查询增强。
- 方案:对齐 chunk 切分(避免跨段落割裂)、换适配语料的 embedding、加查询增强。
// ② 对应落地:先手动取 top-k 看召回质量,再调参
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query("如何配置告警阈值").topK(10).build());
hits.forEach(d -> System.out.println(d.getText().substring(0, 80))); // 逐条人工看相关性
// 召回太少:调大 topK / 放低 similarityThreshold(阈值按你的 embedding 标定)
SearchRequest req = SearchRequest.builder()
.query("如何配置告警阈值")
.topK(20)
.similarityThreshold(0.3)
.build();
③ 检索内容杂乱
- 现象:召回了一堆相关性不高、来源混杂的内容,拼进 prompt 反而污染生成,模型被无关信息带偏。
- 常见根因:chunk 切分不合理(过碎/跨段落)、索引脏数据(重复、过期、未清洗)、向量与关键词召回不平衡、多源未去重。
- 排查:取回 top-k 逐条标注"相关/无关/重复",定位是哪种根因。
- 方案:切分对齐语义边界;索引去重与定期对账;混合检索;召回后去重。
// ③ 对应落地:documentPostProcessors 在召回后做去重/过滤
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(20)
.build())
.documentPostProcessors(docs -> dedup(docs)) // 自定义去重逻辑
.build();
④ 口语化 query 召不准(查询增强)
- 现象:用户问"这玩意儿怎么装",知识库里写的是"安装步骤",直接检索召回不到。
- 根因:口语 query 与文档用词不一致。
- 方案:改写、扩展、HyDE、子问题分解、指代补全。
// ④ 对应落地:查询改写 + 多查询扩展,召回前"改造 query"
// 流水线顺序:原始 query → 改写(1→1) → 扩展(1→3) → 3 个变体分别检索 → 合并去重
// ① 查询改写器:把口语改成检索友好语("这玩意儿怎么装" → "软件安装步骤")
QueryTransformer rewriter = RewriteQueryTransformer.builder()
.ch@tClientBuilder(ChatClient.builder(ch@tModel)) // 改写器内部要调 LLM,需传入能调模型的 ChatClient
.targetSearchSystem("vector store") // 可配置(默认 "vector store"):告诉 LLM 改写后喂给什么检索系统,填进 {target} 占位符
.build();
// ② 多查询扩展器:1 个 query 扩成 3 个语义变体,多角度检索提高命中
QueryExpander expander = MultiQueryExpander.builder()
.ch@tClientBuilder(ChatClient.builder(ch@tModel)) // 同上,内部也要调 LLM 生成变体
.numberOfQueries(3) // 生成 3 个变体,每个变体各自去检索
.build();
// ③ 拼装流水线:先改写 → 再扩展 → 最后检索
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(rewriter) // 挂到"召回前",第一个执行:改写 query
.queryExpander(expander) // 挂到"改写后":把改写结果扩成多个变体
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore).build()) // 真正的检索器:对每个变体去向量库检索,结果合并去重
.build();
关于
targetSearchSystem:可配置、默认"vector store";它只是一个描述性字符串(不是枚举、不校验合法值,写中文"向量数据库"也行),会填进改写提示词的{target}占位符,用来告诉 LLM 改写后的 query 喂给什么检索系统——接向量库就偏向"语义化"改写,接 ES/BM25 就偏向"显式关键词"改写。
⑤ 漏召精确实体(多路召回)
业务上最常用的标准多路召回,是向量语义召回 + BM25 关键词召回两路合并——向量负责语义匹配,BM25 负责精确匹配,弥补向量库对专有名词、ID、编号召回差的缺陷。
多路召回完整链路标准流程:
用户原始 Query
│
├─① BM25 关键词召回 → 候选 A 集合
└─② 向量相似度召回 → 候选 B 集合
↓
③ 合并 A+B,按 documentId 去重,得到大候选池
↓
④ Rerank 重排精筛,截断保留少量 top-N
↓
⑤ 将重排后的文档片段组装上下文,送给 LLM 生成答案
注意:不要直接把两路全部候选丢给大模型,必须过 Rerank 压缩数量,否则 token 直接爆炸。
- 现象:用户问"EM-2025 型号的规格",向量召回找不到这个精确型号(embedding 对数字/型号不敏感),直接漏召。
- 根因:纯向量召回对精确实体(人名/型号/编号)不敏感。
- 方案:向量 + 关键词(BM25)混合,RRF 融合。
// ⑤ 对应落地:核心无内置 BM25,自定义 DocumentRetriever 做"向量 + 关键词 + RRF"
DocumentRetriever hybridRetriever = query -> {
List<Document> vecHits = vectorStore.similaritySearch(
SearchRequest.builder().query(query.text()).topK(20).build());
List<Document> kwHits = keywordSearch(query.text(), 20); // 自建 BM25/ES
return rrfFuse(vecHits, kwHits, 10); // RRF 融合(BM25 与 RRF 见下方说明)
};
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(hybridRetriever)
.build();
BM25 与 RRF 各是什么
- BM25:经典的关键词全文检索打分算法(Elasticsearch 默认用它),靠字面匹配——查"EM-2025"就精确命中"EM-2025",专有名词/ID/编号很准;但不懂语义,问"怎么装"匹配不到"安装步骤"。
- 向量召回:语义匹配强("怎么装"≈"安装步骤"),但数字/ID/型号这类低频精确 token 在向量空间区分度差,容易漏。两者互补,所以两路合并。
- RRF(Reciprocal Rank Fusion,倒数排名融合):两路召回的分数不在一个量纲(向量余弦相似度 vs BM25 分数),不能直接相加。RRF 只看排名、不看原始分数——每个文档在每路的名次取倒数求和:
score(文档) = Σ 1/(k + 名次)(k 通常取 60)。- 例(k=60):文档 A 向量路第 1、BM25 路第 5 →
1/61 + 1/65 ≈ 0.0318;文档 B 向量路第 3、BM25 路第 1 →1/63 + 1/61 ≈ 0.0323,B 更高排前面。排得越靠前贡献越大,天然融合多路、无需调分数量纲。
- 例(k=60):文档 A 向量路第 1、BM25 路第 5 →
⑥ 粗排噪声多、精排提升(重排序)
- 现象:粗排召回 20 条里只有 3 条真正相关,其余噪声喂给模型反而干扰。
- 根因:只用 embedding 余弦相似度,精度不够。
- 方案:rerank 精排、去重、截断 top-n。装前必测——rerank 增加一次网络调用和延迟,只有评测显示失败率偏高才上。
// ⑥ 对应落地:核心无内置 rerank,用 Spring AI Alibaba 的 RetrievalRerankAdvisor(gte-rerank)
RerankModel rerankModel = new DashScopeRerankModel(dashScopeApi,
DashScopeRerankOptions.builder().withModel("gte-rerank").withTopN(3).build());
RetrievalRerankAdvisor advisor = new RetrievalRerankAdvisor(
vectorStore, rerankModel,
SearchRequest.builder().topK(20).similarityThreshold(0.45).build());
ChatClient client = ChatClient.builder(ch@tModel).defaultAdvisors(advisor).build();
⑦ 查不到就瞎编(空上下文)
- 现象:用户问知识库里没有的问题,模型没检索到内容,却一本正经编了个答案。
- 根因:空上下文未处理,模型"自由发挥"。
- 方案:
allowEmptyContext(false)→ "查不到是合法答案"。
// ⑦ 对应落地:空上下文时让模型如实说"查不到",而不是编
ContextualQueryAugmenter augmenter = ContextualQueryAugmenter.builder()
.allowEmptyContext(false) // false = 查不到就明说,禁止瞎编
.build();
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore).build())
.queryAugmenter(augmenter)
.build();
2.4 Spring AI 通用机制:RetrievalAugmentationAdvisor 的处理流程
上面 2.3 的落地代码都围绕 RetrievalAugmentationAdvisor 这一个 Advisor,它内部按固定顺序拼装 RAG 流水线:
原始 query → QueryTransformer(改写/压缩) → QueryExpander(一扩多) → 检索(多 query 并行)
→ DocumentJoiner(合并去重) → DocumentPostProcessor(去重/rerank) → QueryAugmenter(注入上下文) → 发给模型
- QueryTransformer:召回前改写 query(口语改写、压缩历史、翻译)。
- QueryExpander:把一个 query 扩成多个变体,分别检索后合并,提高召回。
- DocumentPostProcessor:召回后处理——去重、rerank、压缩都挂在这里。
- QueryAugmenter:把检索到的文档拼进 prompt 再发给模型;
allowEmptyContext(false)决定"查不到时是否禁止瞎编"。
⚠️ 相似度阈值提醒:
similarityThreshold必须按你自己的 embedding 模型标定——text-embedding 系余弦分数偏低、bge 系偏高,照抄网上数值会导致"查不出来"或"什么都算相关"。
引用溯源:需要答案带引用来源时,用 Spring AI Alibaba 的 DashScopeDocumentRetrievalAdvisor,它会把来源标注成 <ref>[n]</ref> 引用格式。
2.5 评测
构建检索评测集(问题 → 标准答案 + 应召回文档),用召回率 / 命中率 / MRR 度量。混合检索、rerank 都"装前测基线、装后看增益"——没有增益就撤掉,避免徒增延迟。
3. 稳定性与容量工程:异常、超时、重试、限流与配额
3.1 异常分层:先分类,再决定怎么处理
稳定性工程的第一原则是先把异常分好类,不同类的处理策略完全不同:
| 层次 | 典型异常 | 处理策略 |
|---|---|---|
| 网络层 | 连接超时、读超时、连接拒绝 | 重试(指数退避) |
| API 层 | 5xx、限流 429、内容过滤 4xx | 5xx/429 重试,4xx 不重试 |
| 业务层 | 输出 JSON 非法、工具执行失败 | 校验修复/降级,不盲目重试 |
3.2 稳定性常见问题 → 解决方案
先给一张总览表,下面 3.3 逐个展开——每个问题都附具体的"现象"例子和对应的 Spring AI 落地代码:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 请求卡住/超时 | 未设超时或超时不合理 | 分设首 token / 总超时 |
| 偶发失败直接报错 | 未重试或重试条件不当 | 指数退避,只重试可重试错误 |
| 流式输出中途断 | 未处理流式错误 | onError 兜底 + 区分连接/流失败 |
| 主模型不可用 | 无降级 | fallback 模型 + 熔断 |
| JSON 输出非法 | 未做结构化约束 | 校验 + 修复重试 + 默认值 |
| 并发打爆限流/配额 | 无本地限流/配额管控 | 限流 + 排队 + 配额分桶 |
3.3 六类问题逐一拆解
① 请求卡住 / 超时
- 现象:用户发了个复杂问题,等了 60 秒还没返回首 token,前端一直转圈;或者流式输出到一半卡住不动了。
- 根因:没设超时,或只设了一个笼统的总时长,没区分"首 token"和"总时长"。
- 排查:坚控里看"首 token 延迟(TTFT)"和"总延迟"两个分布,定位是"卡在开头"还是"卡在中途"。
- 方案:首 token 超时(TTFT)与总超时分设——前者决定"是否卡住",后者决定"是否限时长";流式一旦超时,已下发的 token 无法撤回,需处理"半截输出"(截断标记或重新生成)。
首 token 超时(TTFT,Time To First Token) = 从你发出请求,到模型吐出第一个字/第一个 token,最多等多久。
为什么要把"首 token"和"总时长"分开:一次 LLM 调用其实分成两个阶段:
发出请求 ──[等第一个 token]──▶ 开始吐字 ──[后续 token 一个个流出来]──▶ 结束
▲ 首 token 延迟 ▲ 剩余时长
└ 首 token 超时管这段 └ 总超时管整段
- 首 token 超时管的是"模型有没有开始干活"——如果模型排队、网络卡住、过载,它会迟迟不吐第一个字。这时你该早点告诉用户"稍后重试",而不是让用户干等。
- 总超时管的是"别让它无限吐下去"——一个长答案本来就要吐很久,这是正常的,所以总时长要放宽。
- 一个典型坑:只设总超时 60s,模型却卡在前 50s 一个 token 都没出,用户干等 50s 才知道失败;单独设 10s 首 token 超时,第 10s 就能发现卡住、立刻兜底。所以通常首 token 超时远小于总超时。
# 示意:流式超时处理(通用思路)
def stream_with_timeout(query):
stream = model.stream(query)
try:
first = await stream.next(timeout=ttft_timeout) # 首 token 超时
except Timeout:
return fallback("服务繁忙,请稍后重试")
yield first
for token in await stream.rest(timeout=total_timeout):
yield token
# 总超时触发时:已下发的 token 无法撤回,需标记截断或重新生成
// ① 对应落地:同步超时用模型级配置;流式超时用 Flux.timeout
// 同步:以 dashscope 为例,属性名以 1.1.2 官方文档为准
// [email protected]=60000
// 流式:超时到点即触发错误,走 onErrorResume 兜底
Flux<String> stream = [email protected]().user(q).stream().content()
.timeout(Duration.ofSeconds(10)); // 首 token 或 token 间隔超过 10s 即超时
② 偶发失败直接报错(重试兜底)
- 现象:高峰期偶发一次 429 或超时,服务就返回"系统错误"给用户,但其实重试一次就好了。
- 根因:没做重试,或重试条件没区分"可重试/不可重试"。
- 方案:指数退避 + 抖动(
base=2、max_retries=3~5、加 jitter);只对可重试错误(超时/5xx/429)重试,非幂等/已产生副作用的错误绝不重试;连续失败触发熔断。
# 示意:带熔断的重试(通用思路)
def call_with_retry(fn, is_idempotent=True, max_retries=4, base=2):
for attempt in range(max_retries):
try:
return fn()
except RetryableError: # 超时/5xx/429
if not is_idempotent: # 副作用操作不重试
raise
if circuit_breaker.open():
return fallback_answer() # 熔断兜底
sleep(base ** attempt + jitter())
return fallback_answer()
# ② 对应落地:Spring AI 重试配置(指数退避 + 只重试可重试错误)
spring.ai.retry.max-attempts=4
spring.ai.retry.backoff.initial-interval=2000
spring.ai.retry.backoff.multiplier=2.0
spring.ai.retry.backoff.max-interval=30000
spring.ai.retry.on-client-errors=false # 4xx 不重试
spring.ai.retry.exclude-on-http-codes=401,403
注意两点:
- 框架的重试针对的是 HTTP 层(超时/5xx/429);"发消息已发出"这类业务副作用的幂等,需要你在工具/业务代码里自行保证。
spring.ai.retry.*只对同步.call()生效:流式.stream()的重试只可能发生在"首 token 之前"(连接阶段);一旦开始吐 token 就无法重试(已下发的 token 收不回),只能靠onErrorResume兜底(见 ③)。这也是已知坑 #3858——ChatClient流式 API 没有 per-request 重试。
③ 流式输出中途失败
- 现象:用户正看着答案一个字一个字往外冒,突然中断,屏幕上留了半句话。
- 根因:流式错误发生在 token 流中途(已输出部分内容),与同步"整段失败"语义完全不同。
- 方案:区分"连接阶段失败"(可整体重试)与"流中途失败"(只能截断/重生成);处理 SSE 断线重连。
// ③ 对应落地:onErrorResume 兜底,别让用户看到"半截话"就断掉
Flux<String> stream = [email protected]().user(q).stream().content()
.onErrorResume(e -> Flux.just("n[生成中断,请稍后重试]"));
④ 主模型不可用(降级兜底)
- 现象:供应商故障或限流过载,整个服务 5xx,没有任何兜底,用户全量失败。
- 根因:无降级策略,把鸡蛋全押在一个模型上。
- 方案:fallback 模型(主模型挂→次选/小模型)、降级答案、结果缓存(同 query 短时命中直接返回)。
// ④ 对应落地:Resilience4j 熔断 + fallback 模型
@CircuitBreaker(name = "llm", fallbackMethod = "fallback")
public String generate(String prompt) {
return [email protected]().user(prompt).call().content();
}
public String fallback(String prompt, Throwable t) {
return fallbackModel.generate(prompt); // 主模型挂了,切次选模型/降级答案
}
⑤ JSON 输出非法(结构化输出保障)
- 现象:模型本该返回 JSON,结果返回了一段散文,或者 JSON 缺字段,代码
JSON.parse直接抛异常。 - 根因:未做结构化约束与校验。
- 方案:JSON 校验 → 格式修复重试(把错误回传给模型修正)→ 约束解码(可选)→ 字段兜底默认值。
// ⑤ 对应落地:BeanOutputConverter 绑定 POJO,强制 JSON schema
BeanOutputConverter<MyResult> converter = new BeanOutputConverter<>(MyResult.class);
String answer = [email protected]()
.user(prompt)
.system("按以下 JSON schema 输出:" + converter.getJsonSchema())
.call()
.content();
MyResult result = converter.convert(answer); // 解析失败抛异常,配合重试/默认值兜底
⑥ 并发打爆限流 / 配额
- 现象:大促并发翻 10 倍,瞬间触发供应商 TPM/QPM 限制,全量 429;或某个租户一个 bug 循环调用,把整个账号的 token 配额打爆,连累其他租户。
- 根因:LLM API 有 TPM(token/分钟)/ QPM / RPM 三类限制,且按账号/项目配额;本地没有对应管控。
- 方案:本地限流(令牌桶/滑动窗口)削峰、排队缓冲 + 优先级、降级模型、配额分桶计数与告警、解析 429 的
Retry-After退避。
// ⑥ 对应落地:Resilience4j 限流 + 隔舱
@RateLimiter(name = "llm")
@Bulkhead(name = "llm")
public String generate(String prompt) {
return [email protected]().user(prompt).call().content();
}
LLM 调用是 I/O 密集阻塞,可开启 JDK 21 虚拟线程(
spring.threads.virtual.enabled=true),以极小开销承载大量并发调用,避免线程池耗尽。
3.4 Spring AI 通用机制补充
异常分层的框架映射:ResponseErrorHandler 把 HTTP 状态映射为 TransientAiException(429/5xx)与 NonTransientAiException(4xx),正好对应上面"可重试/不可重试"的判断——spring.ai.retry.* 只对 Transient 重试。
同步 vs 流式:
.call()返回完整ChatResponse,异常直接抛、可整体重试。.stream()返回Flux<ChatResponse>,错误通过onError下发;超时/出错发生在 token 流中途时无法整体重试,需配合Flux.timeout()或 Resilience4j@TimeLimiter。
可观测性:记录 token、延迟、错误码、重试次数、限流报错占比——没有这些数据,前面所有调优都是盲调。
⚠️ 已知坑(写作重点):
- #4567:auto-config 的重试只针对
TransientAiException(HTTP 状态类),连接超时等网络异常可能不重试,需自行兜底。- #3858:
ChatClient流式 API 暂无 per-request 重试,重试目前只能靠全局配置或@Retryable包装方法。
3.5 评测
以可用性、MTTR、重试成功率、限流报错占比作为稳定性度量基线,上线前后对比。
4. 微调(Fine-tuning)
4.1 决策框架:什么时候该微调
微调不是默认选项,先用下面的决策顺序排除更便宜的手段:
需求:想让模型行为更符合预期
│
├─ 是"知识/事实"缺失? ──────→ 用 RAG(知识会变,微调追不上)
├─ 是"格式/流程"可描述? ────→ 用提示词工程(便宜、可迭代)
└─ 是"稳定风格/私有领域/降本"且难以用提示词表达?
└─→ 才考虑微调
适合微调:稳定输出风格/语气、私有领域术语与格式、让小模型具备特定能力以降低推理成本。 不适合微调:知识频繁更新、只需一次性事实、通用能力提升。
4.2 数据准备
- 格式对齐目标(指令-回答、对话、JSON 等);
- 质量清洗(去低质、去重复、去偏激);
- 多样性覆盖边界 case;
- 去重防止训练集与评测集重叠;
- 数据质量 > 数据量:几千条高质量数据往往优于几十万条脏数据。
4.3 训练配置与常见坑
- 过拟合:训练集表现好、泛化差 → 减 epoch、加正则、增多样性。
- 灾难性遗忘:学会了新技能,忘了通用能力 → 混合通用数据、控制学习率。
- 数据泄露:训练集与评测集重叠导致评测虚高 → 先切分再清洗、留出 holdout 集。
- 超参:epoch/lr 从小开始,早停 + 验证集坚控。
4.4 评估与回退
- 与基线模型对比评测(同评测集);
- 上线灰度(小流量 → 全量);
- 保留回退到基础模型的能力——微调模型出问题随时切回。
4.5 Spring AI 落地(边界要讲清)
- 框架本身不做训练,微调在平台侧(DashScope/百炼训练 API)完成。
- 框架侧落地 = 模型切换与灰度:把微调后模型的 endpoint 作为
DashScopeChatModel的 model 名接入;A/B 对比基线。 - 回退 = 配置切换回基础模型,而非改代码(model 名配置化)。
# 配置化模型名,微调模型与基础模型一键切换
spring:
ai:
dashscope:
ch@t:
options:
model: qwen-plus # 回退时改回基础模型名
# model: ft-xxx-xxx # 灰度时切微调模型
5. 工程化治理与高频主题
5.1 Prompt 即代码
Prompt 应该和代码享受同等待遇:
- 模板化:变量注入、复用片段;
- 版本化:Prompt 入 git,可追溯;
- 灰度发布 + A/B:新旧 Prompt 分流对比;
- 回滚:Prompt 出问题能一键回退;
- 评测基线:每次 Prompt 变更跑评测集。
Spring AI 落地:Prompt 通过 PromptTemplate/资源文件(SystemMessage 模板)管理、随代码版本化;无内置 A/B,需自建开关与评测对比。
// Prompt 即代码:模板随代码一起进 git,变量注入
String templateText = "你是{role},请用简洁中文回答:{question}"; // 实际放 classpath 资源文件,随代码版本化
PromptTemplate template = new PromptTemplate(templateText);
Prompt prompt = template.create(Map.of("role", "客服", "question", "如何退货?"));
String answer = [email protected](prompt).getResult().getOutput().getText();
5.2 模型漂移与升级治理
- 现象:同一个 Prompt,供应商"静默升级"模型后结果变了(格式漂移、能力退化)。
- 方案:
- 锁定模型版本(用带版本的 model 名,而非
latest); - 建立回归评测集;
- 升级前跑基线对比;
- 灰度发布。
- 锁定模型版本(用带版本的 model 名,而非
- Spring AI 落地:model 名配置化接入(如
[email protected]),升级 = 改配置 + 跑评测,回退 = 切回旧配置。
5.3 评测体系与上线发布(各主题评测手段总纲)
把前文各主题的评测手段汇总为一套"变更护栏":
| 主题 | 评测指标 |
|---|---|
| Function Calling | 工具选择准确率、参数正确率 |
| RAG | 召回率 / 命中率 / MRR |
| 稳定性 | 可用性、MTTR、重试成功率、限流占比 |
| 微调 | 与基线模型的对比评测 |
| Prompt/模型变更 | 回归评测集 + A/B + 灰度 |
任何变更(改 Prompt、换模型、加 rerank、调重试)都走"改前跑基线 → 改后看增益 → 灰度 → 全量"的流程。
5.4 多轮对话与会话管理
多轮对话是生产级应用的核心场景,坑也集中:
- 上下文累积与截断:历史越长越贵越慢,需截断策略;
- 指代消解:"它""这个"要补全成明确实体;
- 历史裁剪策略:滑动窗口(保留最近 N 轮)vs 摘要式记忆(压缩旧历史);
- 会话状态持久化与丢失:重启/扩缩容不丢会话;
- 多会话隔离:不同用户/会话互不串扰。
Spring AI 落地:ChatMemory + MessageChatMemoryAdvisor,MessageWindowChatMemory 窗口实现等。
// 滑动窗口记忆:只保留最近 N 条消息,控制上下文长度与成本
MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(20) // 只保留最近 20 条消息,超出自动丢弃
.build();
MessageChatMemoryAdvisor advisor = MessageChatMemoryAdvisor.builder(memory).build();
ChatClient client = ChatClient.builder(ch@tModel)
.defaultAdvisors(advisor) // 挂上 advisor 后,每轮自动带历史上下文
.build();
5.5 幻觉与可控性
- 要求引用溯源(答案必须指向来源);
- 限定"不知道"(查不到就明说,而非编造);
- 置信度校准;
- 约束生成(结构化输出)。
5.6 上下文与成本优化
- 上下文窗口管理(长文本剪枝/摘要);
- Prompt 缓存(重复的 system/工具定义命中缓存降本);
- 批处理与并发;
- 用更小/更便宜的模型做简单子任务。
5.7 安全(与工具调用强联动)
工具调用是最大的攻击面,安全必须与 Function Calling 一起考虑:
- Prompt Injection:包括间接注入——恶意内容藏进检索结果或工具返回,诱导模型越权;
- 工具参数注入:用户输入被塞进工具参数执行危险操作;
- 越权调用工具:模型请求调用用户无权使用的工具;
- 数据隔离与脱敏:租户间数据不串、敏感字段脱敏;
- 输出审查:生成内容做合规过滤。
Spring AI 落地:用 ToolContext 传租户/用户做工具侧鉴权(不暴露给模型)、工具入口白名单与权限校验、敏感字段脱敏。
// 示意:工具侧鉴权——用 ToolContext 传租户,不暴露给模型
@Tool(description = "查询订单。仅当用户明确提供订单号时调用。")
public String getOrder(String orderId, ToolContext context) {
String tenant = (String) context.getContext().get("tenantId"); // 来自应用侧,非模型
if (!authz.canAccess(tenant, orderId)) {
return "无权访问该订单"; // 越权拦截,而非返回真实数据
}
return orderService.get(orderId);
}
6. 总结:问题 → 方案速查表
| 问题现象 | 所属环节 | 快速定位法 | 通用解决方案 | Spring AI / Alibaba 对应 |
|---|---|---|---|---|
| 选错工具/不调用 | Function Calling | 固定温度跑 N 次看命中分布 | 重写 description、few-shot | @Tool(description) |
| 参数幻觉 | Function Calling | 抓取非法参数样本 | 补 schema + 应用侧校验 | @ToolParam + ToolCallback 内校验 |
| 工具死循环 | Function Calling | 观察调用日志循环 | 次数上限 + 终止标记 | 内置 per-tool(40)/总(150) 上限 |
| RAG 匹配度低 | RAG | 手动取 top-k 看相关性 | 查切分/embedding/查询增强 | RetrievalAugmentationAdvisor |
| 检索内容杂乱 | RAG | top-k 逐条标注相关/无关 | 去重、混合检索、重排 | RetrievalRerankAdvisor(Alibaba) |
| 召回漏精确实体 | RAG | 精确词查询命中率 | BM25 混合 + RRF | spring-ai-alibaba-starter-rag |
| 查不到就瞎编 | RAG/幻觉 | 空上下文是否仍生成 | allowEmptyContext(false) | ContextualQueryAugmenter.allowEmptyContext(false) |
| 接口超时/限流 | 稳定性 | 坚控超时/429 占比 | 指数退避 + 熔断 | spring.ai.retry.* + Resilience4j |
| 流式半截输出 | 稳定性 | 流中途失败日志 | 区分连接失败/流失败,TTFT 与总超时分设 | Flux.timeout() / @TimeLimiter |
| JSON 输出非法 | 稳定性 | 解析失败率 | 校验 + 修复重试 + 默认值 | BeanOutputConverter |
| 并发打爆配额 | 容量 | 配额消耗趋势 | 本地限流 + 分桶配额 | Resilience4j @RateLimiter/@Bulkhead |
| 要不要微调 | 模型能力 | 先排 RAG/提示词 | 决策框架 + 数据质量 | 平台侧训练 + 配置化模型名 |
| Prompt 变更失控 | 治理 | 无版本/无回滚 | Prompt 即代码 | PromptTemplate + git |
| 模型静默漂移 | 治理 | 同 prompt 结果变化 | 锁版本 + 回归评测 | 配置化 model 名 |
| 多轮对话记忆丢失 | 会话 | 历史是否正确保留 | 裁剪策略 + 持久化 | ChatMemory + MessageChatMemoryAdvisor |
| 越权调用工具 | 安全 | 审计工具调用 | 工具侧鉴权 | ToolContext + 白名单 |
生产上线前 checklist
- 工具定义四要素齐全(命名 / description / schema / 示例),应用侧有参数校验
- 工具调用有次数上限与终止条件(防死循环)
- 检索有评测集基线(召回率/命中率),混合检索与 rerank 有增益才上
- 超时(TTFT/总超时)、重试(可重试/不可重试区分)、熔断、降级兜底均已配置
- 结构化输出有校验与修复重试
- 限流与配额预算已建立并接入告警
- 可观测性(token/延迟/错误码/重试)已埋点
- Prompt 入版本库、可回滚;模型版本锁定
- 微调/换模型有回退到基线的能力
- 工具调用侧有鉴权与越权拦截