首页
看点啥
插画图片
首页 科技看点 AI 大模型应用常见问题与工程化解决思路

AI 大模型应用常见问题与工程化解决思路

2026-10-19 0

大模型接入业务并不等于应用已经具备生产能力。模型输出存在非确定性,外部调用又受到延迟、成本和服务稳定性的影响,任何一个环节缺少约束都可能放大故障。下面将从工具调用、RAG、生成稳定性和工程治理等方面,逐步分析常见问题及对应的落地方案。

面向生产级进阶工程师的实战手册:把 LLM 当生产组件时,你会踩到的坑、怎么排查、怎么兜底,以及这套方法论如何落到 Spring AI / Spring AI Alibaba 上。


版本基线声明

本文所有代码示例基于以下版本,读者照抄前请先对齐:

组件版本
Spring AI1.1.2
Spring AI Alibaba1.1.2.2
Spring Boot3.5.9
JDK21+

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

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

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

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

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

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

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

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

1.2 生产常见问题 → 解决方案

先给一张总览表,下面 1.3 逐个展开——每个问题都附具体的"现象"例子和对应的 Spring AI 落地代码:

问题现象根因解决方案
选错工具 / 该调用却不调用描述模糊、语义重叠、缺"何时用"指引重写 description 为"做什么+何时用";拆分重叠工具;few-shot 示例
参数幻觉 / 缺失必填项schema 约束不清、无示例、枚举未闭合补 required/enum/参数描述;应用侧参数校验与二次补全
多工具混淆功能边界重叠合并为单工具+枚举参数,或加"选择准则"说明
工具结果过大撑爆上下文工具返回未裁剪结果裁剪/摘要、只保留关键字段、分页
多步/并行调用编排失控未设调用次数与依赖控制设 max_iterations、并行调用合并、结果聚合
工具互相调用导致死循环缺少终止条件次数上限 + 终止标记 + 超时兜底

1.3 六类问题逐一拆解

① 选错工具 / 该调用却不调用

// ① 对应落地:description 三要素,直接决定"选得准不准"
@Tool(description = "查询指定城市当天天气。当用户询问某地天气/气温/是否下雨时调用;当用户只问穿衣建议、或未提及具体城市时不要调用。")
public String getWeather(@ToolParam(description = "城市名,如'杭州'") String city) {
    return weatherService.fetch(city);
}

② 参数幻觉 / 缺失必填项

// ② 对应落地:方法体里做参数校验,不信任模型给的参数
@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() : "未找到该订单,请向用户确认订单号是否正确";
}

③ 多工具混淆

// ③ 对应落地:合并为单工具 + 枚举参数,收窄模型的选择空间
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);
}

④ 工具结果过大撑爆上下文

// ④ 对应落地:在工具方法内部裁剪,别把原始大结果直接回填
@Tool(description = "查询系统日志。返回最近的关键日志摘要。")
public String getLogs(@ToolParam(description = "服务名") String service) {
    List logs = logService.query(service, 1000);      // 最多取 1000 条
    String summary = LogSummarizer.summarize(logs, 20);     // 只返回 20 条关键摘要 + 统计
    return summary + "\n(共 " + logs.size() + " 条,如需明细可继续查询)";
}

⑤ 多步/并行调用编排失控

// ⑤ 对应落地:toolCalls 从哪来 + 如何并行执行

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

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

ChatResponse response = [email protected](prompt);            // ① 直接调 ch@tModel,不自动执行工具
AssistantMessage output = response.getResult().getOutput();

if (output.hasToolCalls()) {
    List toolCalls = output.getToolCalls();      // ② ← toolCalls 的来源在这里

    // ③ 并行执行所有 tool_call(ToolCall 只有 name + arguments)
    List> futures = toolCalls.stream()
            .map(tc -> CompletableFuture.supplyAsync(() ->
                    callbacks.get(tc.name()).call(tc.arguments())))
            .toList();
    List results = futures.stream().map(CompletableFuture::join).toList();

    // ④ 把结果包装成 tool message 拼回历史,再调模型进入下一轮循环
    //    (顺序执行时这一步由 ToolCallingManager.executeToolCalls() 替你完成;
    //      并行执行需自己按 tool_call.id 组装 ToolResponseMessage)
}

说明:

  • buildCallbacks():用 MethodToolCallbackProvider 把 @Tool 方法包装成 ToolCallback,再按 getToolDefinition().name() 建立 Map。
  • 默认模式(推荐 90% 场景):.call() 自动把工具循环跑完,无需关心 toolCalls。
  • 手动模式(要并行/自定义):直接 [email protected](prompt),从 response.getResult().getOutput().getToolCalls() 取 List;每个 ToolCall 只有 name() 和 arguments(),用 name() 查 ToolCallback、传 arguments() 执行。
  • Spring AI 1.x 默认顺序执行;并行只能手动 CompletableFuture(或升级 2.0 的异步工具调用,Issue #4755)。调用次数上限见 ⑥。

⑥ 工具互相调用导致死循环

# 示意:带终止条件的工具执行循环(通用思路)
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 决定走哪条路:

// 方式一:自定义 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 流式:

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 七类问题逐一拆解

① 文档解析质量差(数据前置)

// ① 对应落地:按类型选 reader 读取 → 切分 → 入库
DocumentReader reader = new PagePdfDocumentReader(new FileSystemResource("docs/sales.pdf"));
// Word/HTML 用 TikaDocumentReader;扫描件先走 OCR

TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(600)          // 按自身语料标定,过碎/过大都会漏召
        .withKeepSeparator(true)
        .build();

List docs = splitter.apply(reader.get());
vectorStore.add(docs);

② 匹配度低:该召到的没召到

// ② 对应落地:先手动取 top-k 看召回质量,再调参
List 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();

③ 检索内容杂乱

// ③ 对应落地:documentPostProcessors 在召回后做去重/过滤
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(20)
                .build())
        .documentPostProcessors(docs -> dedup(docs))   // 自定义去重逻辑
        .build();

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

// ④ 对应落地:查询改写 + 多查询扩展,召回前"改造 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 直接爆炸。

// ⑤ 对应落地:核心无内置 BM25,自定义 DocumentRetriever 做"向量 + 关键词 + RRF"
DocumentRetriever hybridRetriever = query -> {
    List vecHits = vectorStore.similaritySearch(
            SearchRequest.builder().query(query.text()).topK(20).build());
    List kwHits = keywordSearch(query.text(), 20);   // 自建 BM25/ES
    return rrfFuse(vecHits, kwHits, 10);                        // RRF 融合(BM25 与 RRF 见下方说明)
};

RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(hybridRetriever)
        .build();

BM25 与 RRF 各是什么

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

// ⑥ 对应落地:核心无内置 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();

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

// ⑦ 对应落地:空上下文时让模型如实说"查不到",而不是编
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(注入上下文) → 发给模型

⚠️ 相似度阈值提醒:similarityThreshold 必须按你自己的 embedding 模型标定——text-embedding 系余弦分数偏低、bge 系偏高,照抄网上数值会导致"查不出来"或"什么都算相关"。

引用溯源:需要答案带引用来源时,用 Spring AI Alibaba 的 DashScopeDocumentRetrievalAdvisor,它会把来源标注成 [n] 引用格式。

2.5 评测

构建检索评测集(问题 → 标准答案 + 应召回文档),用召回率 / 命中率 / MRR 度量。混合检索、rerank 都"装前测基线、装后看增益"——没有增益就撤掉,避免徒增延迟。


3. 稳定性与容量工程:异常、超时、重试、限流与配额

3.1 异常分层:先分类,再决定怎么处理

稳定性工程的第一原则是先把异常分好类,不同类的处理策略完全不同:

层次典型异常处理策略
网络层连接超时、读超时、连接拒绝重试(指数退避)
API 层5xx、限流 429、内容过滤 4xx5xx/429 重试,4xx 不重试
业务层输出 JSON 非法、工具执行失败校验修复/降级,不盲目重试

3.2 稳定性常见问题 → 解决方案

先给一张总览表,下面 3.3 逐个展开——每个问题都附具体的"现象"例子和对应的 Spring AI 落地代码:

问题现象根因解决方案
请求卡住/超时未设超时或超时不合理分设首 token / 总超时
偶发失败直接报错未重试或重试条件不当指数退避,只重试可重试错误
流式输出中途断未处理流式错误onError 兜底 + 区分连接/流失败
主模型不可用无降级fallback 模型 + 熔断
JSON 输出非法未做结构化约束校验 + 修复重试 + 默认值
并发打爆限流/配额无本地限流/配额管控限流 + 排队 + 配额分桶

3.3 六类问题逐一拆解

① 请求卡住 / 超时

首 token 超时(TTFT,Time To First Token) = 从你发出请求,到模型吐出第一个字/第一个 token,最多等多久。

为什么要把"首 token"和"总时长"分开:一次 LLM 调用其实分成两个阶段:

发出请求 ──[等第一个 token]──▶ 开始吐字 ──[后续 token 一个个流出来]──▶ 结束
          ▲ 首 token 延迟              ▲ 剩余时长
          └ 首 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 stream = [email protected]().user(q).stream().content()
        .timeout(Duration.ofSeconds(10));   // 首 token 或 token 间隔超过 10s 即超时

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

# 示意:带熔断的重试(通用思路)
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 重试。

③ 流式输出中途失败

// ③ 对应落地:onErrorResume 兜底,别让用户看到"半截话"就断掉
Flux stream = [email protected]().user(q).stream().content()
        .onErrorResume(e -> Flux.just("\n[生成中断,请稍后重试]"));

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

// ④ 对应落地: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 输出非法(结构化输出保障)

// ⑤ 对应落地:BeanOutputConverter 绑定 POJO,强制 JSON schema
BeanOutputConverter converter = new BeanOutputConverter<>(MyResult.class);

String answer = [email protected]()
        .user(prompt)
        .system("按以下 JSON schema 输出:" + converter.getJsonSchema())
        .call()
        .content();

MyResult result = converter.convert(answer);   // 解析失败抛异常,配合重试/默认值兜底

⑥ 并发打爆限流 / 配额

// ⑥ 对应落地: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 流式:

可观测性:记录 token、延迟、错误码、重试次数、限流报错占比——没有这些数据,前面所有调优都是盲调。

⚠️ 已知坑(写作重点):

  • #4567:auto-config 的重试只针对 TransientAiException(HTTP 状态类),连接超时等网络异常可能不重试,需自行兜底。
  • #3858:ChatClient 流式 API 暂无 per-request 重试,重试目前只能靠全局配置或 @Retryable 包装方法。

3.5 评测

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


4. 微调(Fine-tuning)

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

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

需求:想让模型行为更符合预期
  │
  ├─ 是"知识/事实"缺失? ──────→ 用 RAG(知识会变,微调追不上)
  ├─ 是"格式/流程"可描述? ────→ 用提示词工程(便宜、可迭代)
  └─ 是"稳定风格/私有领域/降本"且难以用提示词表达?
        └─→ 才考虑微调

适合微调:稳定输出风格/语气、私有领域术语与格式、让小模型具备特定能力以降低推理成本。 不适合微调:知识频繁更新、只需一次性事实、通用能力提升。

4.2 数据准备

4.3 训练配置与常见坑

4.4 评估与回退

4.5 Spring AI 落地(边界要讲清)

# 配置化模型名,微调模型与基础模型一键切换
spring:
  ai:
    dashscope:
      ch@t:
        options:
          model: qwen-plus            # 回退时改回基础模型名
          # model: ft-xxx-xxx        # 灰度时切微调模型

5. 工程化治理与高频主题

5.1 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 模型漂移与升级治理

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

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

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

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

5.4 多轮对话与会话管理

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

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 上下文与成本优化

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

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

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
检索内容杂乱RAGtop-k 逐条标注相关/无关去重、混合检索、重排RetrievalRerankAdvisor(Alibaba)
召回漏精确实体RAG精确词查询命中率BM25 混合 + RRFspring-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

喜欢(0)

上一篇

追踪 Claude Code 用量及文件修改过程

追踪 Claude Code 用量及文件修改过程

下一篇

Claude Opus 4、GPT-5 等 AI 编程助手在 RubberDuckBench 上表现如何?

Claude Opus 4、GPT-5 等 AI 编程助手在 RubberDuckBench 上表现如何?
猜你喜欢