AI 大模型应用遇到的问题及解决方案

面向生产级进阶工程师的实战手册:把 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 接口。把它的特性不当回事,就会在工具调用、检索、稳定性、模型能力四个维度反复踩坑。

下面是典型链路的"问题地图",每个环节都标注了高频故障点:

javascript 复制代码
用户输入
  │
  ▼
① 意图识别 / 工具调用  ── 选错工具、参数幻觉、不调用、死循环
  │
  ▼
② 检索(RAG)        ── 匹配度低、内容杂乱、漏召、文档解析差
  │
  ▼
③ 生成               ── 幻觉、格式不稳定、超时、限流、中途失败
  │
  ▼
④ 输出校验/结构化     ── JSON 非法、字段缺失、引用错误
  │
  ▼
返回

全文按链路环节 + 主题双轨组织:

  • 第 1 章 Function Calling第 2 章 RAG第 3 章稳定性与容量第 4 章微调 对应上图的四个环节;
  • 第 5 章 是横切的工程化治理主题(Prompt 治理、模型漂移、多轮对话、安全等)。

贯穿全文的两条横切约定,读者需要记住:

  1. 同步 vs 流式 :凡是涉及工具调用、超时、重试、错误处理、结构化输出的地方,.call()(同步)和 .stream()(流式)语义有本质区别------流式一旦开始下发 token,"失败"和"重试"的含义就变了(详见第 1、3 章)。
  2. 评测(Eval):任何优化都应该是"先测基线、改后看增益"。每个主题末尾都附一句"如何评测",第 5.3 节统一汇总。

1. Function Calling 核心逻辑与生产问题

1.1 核心逻辑:模型到底如何"精准匹配"工具

先破除一个常见误解:模型并不会"调用"你的工具,它只输出一个结构化的"调用意图"------一个工具名加一串参数 JSON。

scss 复制代码
system + tools(定义) + user
        │
        ▼
模型输出  tool_call { name: "get_weather", arguments: { "city": "杭州" } }
        │
        ▼
【应用侧】真正执行 get_weather("杭州")  ← 执行权在你手里,不在模型手里
        │
        ▼
把执行结果回填进消息,模型续写最终答案

"执行权在应用侧"是整个 Function Calling 认知的地基。它意味着:模型只能"请求",不能"执行",所有副作用(查库、调外部 API、发消息)都由你的代码完成,因此也由你的代码负责校验、鉴权和兜底。

那么模型为什么有时"选得准"、有时"选得离谱"?"精准匹配"由四件事共同决定:

  1. 工具 description 的质量------不仅要写"做什么",更要写"什么时候该用、什么时候不该用";
  2. 参数 JSON Schema 的质量 ------requiredenum、类型、参数描述是否清晰;
  3. 命名清晰度------工具名和参数名是否无歧义、不重叠;
  4. 提示词里的引导------是否给出了调用时机、约束和反例。

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_weathersearch_stock 的描述都含糊,模型只能靠猜。
  • 排查 :同一个 query 固定 temperature=0 连跑 20 次,统计每次调了哪个工具;命中分散、时对时错,基本是工具定义质量问题,而不是模型能力问题。
  • 方案:description 重写为"做什么 + 何时用 + 何时不用"三要素;拆开语义重叠的工具;补 few-shot 示例。
java 复制代码
// ① 对应落地: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);应用侧做参数校验------不要信任模型给的参数,校验不通过就返回提示让模型去反问用户,而不是硬执行。
java 复制代码
// ② 对应落地:方法体里做参数校验,不信任模型给的参数
@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)),让模型只需选参数、不必在两个工具之间二选一。
java 复制代码
// ③ 对应落地:合并为单工具 + 枚举参数,收窄模型的选择空间
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 万字的文档。
  • 根因:工具返回未裁剪,把"查询"和"精读全文"混在了一次调用里。
  • 方案:工具层就做裁剪/摘要------只返回关键字段、截断超长内容、分页返回,把"要不要看全文"留给下一轮对话。
java 复制代码
// ④ 对应落地:在工具方法内部裁剪,别把原始大结果直接回填
@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);能并行的调用合并一次下发、并发执行;有依赖的串行执行 + 结果聚合。
java 复制代码
// ⑤ 对应落地:toolCalls 从哪来 + 如何并行执行

// 先说结论:默认 ChatClient.call() 由 DefaultToolCallingManager 顺序执行完整个循环,
// 你拿不到中间的 toolCalls,也不需要拿。只有要"并行/自定义编排"时才手动驱动。

Map<String, ToolCallback> callbacks = buildCallbacks();  // @Tool 方法 → 按工具名索引的 ToolCallback

ChatResponse response = chatModel.call(prompt);            // ① 直接调 chatModel,不自动执行工具
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。
  • 手动模式(要并行/自定义) :直接 chatModel.call(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 打满、账单爆炸。
  • 根因:缺少终止条件。
  • 方案 :三层兜底------调用次数上限 (硬终止)、终止标记 (检测到重复工具+参数组合即停)、超时兜底
vbnet 复制代码
# 示意:带终止条件的工具执行循环(通用思路)
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("超出最大调用次数")
java 复制代码
// ⑥ 对应落地:Spring AI 已内置调用次数上限------
// DefaultToolCallingManager 默认 per-tool 40 次、整轮总调用 150 次,默认即可挡住绝大多数死循环。
// 如需更严格,可自定义 ToolCallingManager 在其 builder 上调低上限。
// "重复工具+参数即停"的终止标记默认不内置,需在工具侧自行实现(对应上方伪代码的 seen 逻辑)。

1.4 Spring AI 通用机制:一次工具调用到底发生了什么

1.3 讲的是"每类问题怎么写工具",这一节讲"工具调用的底层怎么运转"------理解这个,前面很多做法(校验、裁剪、兜底)就顺理成章了。

从一个最小例子讲起

java 复制代码
@Tool(description = "查询指定城市当天天气")
public String getWeather(@ToolParam(description = "城市名") String city) {
    return weatherService.fetch(city);   // 你只写了这一行真正的业务逻辑
}

String answer = ChatClient.builder(chatModel)
        .defaultTools(new WeatherTools())
        .build()
        .prompt()
        .user("杭州天气怎么样?")
        .call()
        .content();

你只写了 getWeather 一个方法,剩下整轮循环都是 Spring AI 替你做的。背后实际发生的是

swift 复制代码
第 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:异常中断整次调用,由你的代码兜底(记日志、返回降级答案等)。适合"不想让模型看到内部错误细节"或"必须让调用方感知"的场景。
java 复制代码
// 方式一:自定义 Bean,全局改成"直接抛出"
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
    return new DefaultToolExecutionExceptionProcessor(true);   // true = 直接抛出
}

// 方式二:只对特定异常直接抛出,其余回传给模型
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
    return new DefaultToolExecutionExceptionProcessor(
            false,                                    // 默认回传模型
            List.of(PermissionDeniedException.class)  // 白名单:这些异常直接抛出
    );
}
properties 复制代码
# 方式三:Spring Boot 属性配置(最简单)
spring.ai.tools.throw-exception-on-error=true   # true=直接抛出;false(默认)=回传模型

注意:默认只对 RuntimeException 的 message 做"回传模型"处理;受检异常和 Error(如 IOExceptionOutOfMemoryError无论如何都会直接抛出,不经过回传。

ToolContext:给工具传"模型不该知道"的数据

java 复制代码
@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 模型"。先按链路逐层定位,确定是哪一层出的问题:

scss 复制代码
数据解析 → 切分 → 索引 → 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、表格保留结构);解析后清洗(去页眉页脚、去乱码、合并断行)。
java 复制代码
// ① 对应落地:按类型选 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、加查询增强。
java 复制代码
// ② 对应落地:先手动取 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 逐条标注"相关/无关/重复",定位是哪种根因。
  • 方案:切分对齐语义边界;索引去重与定期对账;混合检索;召回后去重。
java 复制代码
// ③ 对应落地:documentPostProcessors 在召回后做去重/过滤
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(20)
                .build())
        .documentPostProcessors(docs -> dedup(docs))   // 自定义去重逻辑
        .build();

④ 口语化 query 召不准(查询增强)

  • 现象:用户问"这玩意儿怎么装",知识库里写的是"安装步骤",直接检索召回不到。
  • 根因:口语 query 与文档用词不一致。
  • 方案:改写、扩展、HyDE、子问题分解、指代补全。
java 复制代码
// ④ 对应落地:查询改写 + 多查询扩展,召回前"改造 query"
// 流水线顺序:原始 query → 改写(1→1) → 扩展(1→3) → 3 个变体分别检索 → 合并去重

// ① 查询改写器:把口语改成检索友好语("这玩意儿怎么装" → "软件安装步骤")
QueryTransformer rewriter = RewriteQueryTransformer.builder()
        .chatClientBuilder(ChatClient.builder(chatModel))   // 改写器内部要调 LLM,需传入能调模型的 ChatClient
        .targetSearchSystem("vector store")                 // 可配置(默认 "vector store"):告诉 LLM 改写后喂给什么检索系统,填进 {target} 占位符
        .build();

// ② 多查询扩展器:1 个 query 扩成 3 个语义变体,多角度检索提高命中
QueryExpander expander = MultiQueryExpander.builder()
        .chatClientBuilder(ChatClient.builder(chatModel))   // 同上,内部也要调 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、编号召回差的缺陷。

多路召回完整链路标准流程

css 复制代码
用户原始 Query
    │
    ├─① BM25 关键词召回 → 候选 A 集合
    └─② 向量相似度召回 → 候选 B 集合
          ↓
③ 合并 A+B,按 documentId 去重,得到大候选池
          ↓
④ Rerank 重排精筛,截断保留少量 top-N
          ↓
⑤ 将重排后的文档片段组装上下文,送给 LLM 生成答案

注意:不要直接把两路全部候选丢给大模型,必须过 Rerank 压缩数量,否则 token 直接爆炸。

  • 现象:用户问"EM-2025 型号的规格",向量召回找不到这个精确型号(embedding 对数字/型号不敏感),直接漏召。
  • 根因:纯向量召回对精确实体(人名/型号/编号)不敏感。
  • 方案:向量 + 关键词(BM25)混合,RRF 融合。
java 复制代码
// ⑤ 对应落地:核心无内置 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 更高排前面。排得越靠前贡献越大,天然融合多路、无需调分数量纲。

⑥ 粗排噪声多、精排提升(重排序)

  • 现象:粗排召回 20 条里只有 3 条真正相关,其余噪声喂给模型反而干扰。
  • 根因:只用 embedding 余弦相似度,精度不够。
  • 方案 :rerank 精排、去重、截断 top-n。装前必测------rerank 增加一次网络调用和延迟,只有评测显示失败率偏高才上。
java 复制代码
// ⑥ 对应落地:核心无内置 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(chatModel).defaultAdvisors(advisor).build();

⑦ 查不到就瞎编(空上下文)

  • 现象:用户问知识库里没有的问题,模型没检索到内容,却一本正经编了个答案。
  • 根因:空上下文未处理,模型"自由发挥"。
  • 方案allowEmptyContext(false) → "查不到是合法答案"。
java 复制代码
// ⑦ 对应落地:空上下文时让模型如实说"查不到",而不是编
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 流水线:

scss 复制代码
原始 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 调用其实分成两个阶段:

css 复制代码
发出请求 ──[等第一个 token]──▶ 开始吐字 ──[后续 token 一个个流出来]──▶ 结束
          ▲ 首 token 延迟              ▲ 剩余时长
          └ 首 token 超时管这段        └ 总超时管整段
  • 首 token 超时 管的是"模型有没有开始干活"------如果模型排队、网络卡住、过载,它会迟迟不吐第一个字。这时你该早点告诉用户"稍后重试",而不是让用户干等。
  • 总超时 管的是"别让它无限吐下去"------一个长答案本来就要吐很久,这是正常的,所以总时长要放宽。
  • 一个典型坑:只设总超时 60s,模型却卡在前 50s 一个 token 都没出,用户干等 50s 才知道失败;单独设 10s 首 token 超时,第 10s 就能发现卡住、立刻兜底。所以通常首 token 超时远小于总超时
python 复制代码
# 示意:流式超时处理(通用思路)
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 无法撤回,需标记截断或重新生成
java 复制代码
// ① 对应落地:同步超时用模型级配置;流式超时用 Flux.timeout
// 同步:以 dashscope 为例,属性名以 1.1.2 官方文档为准
// spring.ai.dashscope.chat.options.timeout=60000

// 流式:超时到点即触发错误,走 onErrorResume 兜底
Flux<String> stream = chatClient.prompt().user(q).stream().content()
        .timeout(Duration.ofSeconds(10));   // 首 token 或 token 间隔超过 10s 即超时

② 偶发失败直接报错(重试兜底)

  • 现象 :高峰期偶发一次 429 或超时,服务就返回"系统错误"给用户,但其实重试一次就好了
  • 根因:没做重试,或重试条件没区分"可重试/不可重试"。
  • 方案 :指数退避 + 抖动(base=2max_retries=3~5、加 jitter);只对可重试错误(超时/5xx/429)重试,非幂等/已产生副作用的错误绝不重试;连续失败触发熔断。
python 复制代码
# 示意:带熔断的重试(通用思路)
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()
properties 复制代码
# ② 对应落地: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 断线重连。
java 复制代码
// ③ 对应落地:onErrorResume 兜底,别让用户看到"半截话"就断掉
Flux<String> stream = chatClient.prompt().user(q).stream().content()
        .onErrorResume(e -> Flux.just("\n[生成中断,请稍后重试]"));

④ 主模型不可用(降级兜底)

  • 现象:供应商故障或限流过载,整个服务 5xx,没有任何兜底,用户全量失败。
  • 根因:无降级策略,把鸡蛋全押在一个模型上。
  • 方案:fallback 模型(主模型挂→次选/小模型)、降级答案、结果缓存(同 query 短时命中直接返回)。
java 复制代码
// ④ 对应落地:Resilience4j 熔断 + fallback 模型
@CircuitBreaker(name = "llm", fallbackMethod = "fallback")
public String generate(String prompt) {
    return chatClient.prompt().user(prompt).call().content();
}

public String fallback(String prompt, Throwable t) {
    return fallbackModel.generate(prompt);   // 主模型挂了,切次选模型/降级答案
}

⑤ JSON 输出非法(结构化输出保障)

  • 现象 :模型本该返回 JSON,结果返回了一段散文,或者 JSON 缺字段,代码 JSON.parse 直接抛异常。
  • 根因:未做结构化约束与校验。
  • 方案:JSON 校验 → 格式修复重试(把错误回传给模型修正)→ 约束解码(可选)→ 字段兜底默认值。
java 复制代码
// ⑤ 对应落地:BeanOutputConverter 绑定 POJO,强制 JSON schema
BeanOutputConverter<MyResult> converter = new BeanOutputConverter<>(MyResult.class);

String answer = chatClient.prompt()
        .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 退避。
java 复制代码
// ⑥ 对应落地:Resilience4j 限流 + 隔舱
@RateLimiter(name = "llm")
@Bulkhead(name = "llm")
public String generate(String prompt) {
    return chatClient.prompt().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 状态类),连接超时等网络异常可能不重试,需自行兜底。
  • #3858ChatClient 流式 API 暂无 per-request 重试,重试目前只能靠全局配置或 @Retryable 包装方法。

3.5 评测

可用性、MTTR、重试成功率、限流报错占比作为稳定性度量基线,上线前后对比。


4. 微调(Fine-tuning)

4.1 决策框架:什么时候该微调

微调不是默认选项,先用下面的决策顺序排除更便宜的手段:

arduino 复制代码
需求:想让模型行为更符合预期
  │
  ├─ 是"知识/事实"缺失? ──────→ 用 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 名配置化)。
yaml 复制代码
# 配置化模型名,微调模型与基础模型一键切换
spring:
  ai:
    dashscope:
      chat:
        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,需自建开关与评测对比。

java 复制代码
// Prompt 即代码:模板随代码一起进 git,变量注入
String templateText = "你是{role},请用简洁中文回答:{question}";  // 实际放 classpath 资源文件,随代码版本化
PromptTemplate template = new PromptTemplate(templateText);
Prompt prompt = template.create(Map.of("role", "客服", "question", "如何退货?"));
String answer = chatModel.call(prompt).getResult().getOutput().getText();

5.2 模型漂移与升级治理

  • 现象:同一个 Prompt,供应商"静默升级"模型后结果变了(格式漂移、能力退化)。
  • 方案
    • 锁定模型版本(用带版本的 model 名,而非 latest);
    • 建立回归评测集;
    • 升级前跑基线对比;
    • 灰度发布。
  • Spring AI 落地 :model 名配置化接入(如 spring.ai.dashscope.chat.options.model),升级 = 改配置 + 跑评测,回退 = 切回旧配置。

5.3 评测体系与上线发布(各主题评测手段总纲)

把前文各主题的评测手段汇总为一套"变更护栏":

主题 评测指标
Function Calling 工具选择准确率、参数正确率
RAG 召回率 / 命中率 / MRR
稳定性 可用性、MTTR、重试成功率、限流占比
微调 与基线模型的对比评测
Prompt/模型变更 回归评测集 + A/B + 灰度

任何变更(改 Prompt、换模型、加 rerank、调重试)都走"改前跑基线 → 改后看增益 → 灰度 → 全量"的流程。

5.4 多轮对话与会话管理

多轮对话是生产级应用的核心场景,坑也集中:

  • 上下文累积与截断:历史越长越贵越慢,需截断策略;
  • 指代消解:"它""这个"要补全成明确实体;
  • 历史裁剪策略:滑动窗口(保留最近 N 轮)vs 摘要式记忆(压缩旧历史);
  • 会话状态持久化与丢失:重启/扩缩容不丢会话;
  • 多会话隔离:不同用户/会话互不串扰。

Spring AI 落地ChatMemory + MessageChatMemoryAdvisorMessageWindowChatMemory 窗口实现等。

java 复制代码
// 滑动窗口记忆:只保留最近 N 条消息,控制上下文长度与成本
MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
        .maxMessages(20)          // 只保留最近 20 条消息,超出自动丢弃
        .build();

MessageChatMemoryAdvisor advisor = MessageChatMemoryAdvisor.builder(memory).build();

ChatClient client = ChatClient.builder(chatModel)
        .defaultAdvisors(advisor)   // 挂上 advisor 后,每轮自动带历史上下文
        .build();

5.5 幻觉与可控性

  • 要求引用溯源(答案必须指向来源);
  • 限定"不知道"(查不到就明说,而非编造);
  • 置信度校准;
  • 约束生成(结构化输出)。

5.6 上下文与成本优化

  • 上下文窗口管理(长文本剪枝/摘要);
  • Prompt 缓存(重复的 system/工具定义命中缓存降本);
  • 批处理与并发;
  • 用更小/更便宜的模型做简单子任务。

5.7 安全(与工具调用强联动)

工具调用是最大的攻击面,安全必须与 Function Calling 一起考虑:

  • Prompt Injection :包括间接注入------恶意内容藏进检索结果或工具返回,诱导模型越权;
  • 工具参数注入:用户输入被塞进工具参数执行危险操作;
  • 越权调用工具:模型请求调用用户无权使用的工具;
  • 数据隔离与脱敏:租户间数据不串、敏感字段脱敏;
  • 输出审查:生成内容做合规过滤。

Spring AI 落地 :用 ToolContext 传租户/用户做工具侧鉴权(不暴露给模型)、工具入口白名单与权限校验、敏感字段脱敏。

java 复制代码
// 示意:工具侧鉴权------用 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 入版本库、可回滚;模型版本锁定
  • 微调/换模型有回退到基线的能力
  • 工具调用侧有鉴权与越权拦截
相关推荐
Sunny_G3 小时前
鸿蒙 Markdown 编辑器表格所见即所得:七个版本的渲染重构(CodeMirror Decoration 实战)
ai编程·harmonyos
烬羽3 小时前
单 Agent 是直线代码,多 Agent 是一张会分岔、循环、暂停的图:LangGraph 工作流编排
agent·ai编程
feiyu_gao3 小时前
Cocreation Framework:一套给所有人的 AI 协作思考系统
设计模式·aigc·ai编程
星陨5403 小时前
02_向量数据库性能调优:HNSW参数、内存管理与生产级优化实践
ai编程
艺杯羹3 小时前
AI编程时代软件工程怎么学:从底层思维认知到驱动智能体的架构跃迁
java·人工智能·ai·架构·软件工程·ai编程
小虎AI生活4 小时前
GEO 落地实战:如何让企业的信息被大模型检索、信任并引用
ai编程
一航jason4 小时前
大模型“上车“评估参数列表
人工智能·ai·aigc·ai编程·ai-native
尘埃落定wf4 小时前
AGENTS.md 与 CLAUDE.md:如何为 AI 编程助手建立项目协作规范
人工智能·ai编程
学者猫头鹰4 小时前
Spring AI Alibaba基础教程
ai编程