本章目标
第 13 章我们讲了 Redis。
Redis 解决的是高频读取、短期状态和任务并发协调问题。
这一章继续讲系统稳定性。
KnowHub 中最需要稳定性保护的接口,是知识库问答接口。
原因很简单:一次问答不是一次普通数据库查询。
它背后可能包含:
text
用户鉴权
知识库归属校验
问题 Embedding
pgvector 检索
Prompt 构造
聊天模型调用
qa_log 写入
qa_reference 写入
其中聊天模型还是外部依赖。
外部依赖可能变慢、可能失败、可能被限流,也可能产生较高费用。
所以一个企业级 RAG 平台不能只关心"模型能不能回答",还要关心:
text
请求太多时怎么办?
模型超时时怎么办?
模型异常时怎么办?
用户应该看到什么?
后台应该记录什么?
本章要讲清楚:
- 为什么 AI 问答接口必须限流。
- Sentinel 在 KnowHub 中保护哪个入口。
- Sentinel 的资源名、QPS 和 blockHandler 是什么。
- 本地限流规则和生产动态规则有什么区别。
- AI 模型调用为什么要设置 timeout。
KnowledgeChatFallbackProperties如何配置降级。CompletableFuture如何配合线程池做超时控制。modelSuccess、modelFallback、modelErrorMessage分别表示什么。- 限流、降级、无检索结果和业务异常有什么区别。
- 常见限流和降级问题如何排查。
下面是本章的整体结构图:
#mermaid-svg-XeBNSSmZkSABlycM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XeBNSSmZkSABlycM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XeBNSSmZkSABlycM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XeBNSSmZkSABlycM .error-icon{fill:#552222;}#mermaid-svg-XeBNSSmZkSABlycM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XeBNSSmZkSABlycM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XeBNSSmZkSABlycM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XeBNSSmZkSABlycM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XeBNSSmZkSABlycM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XeBNSSmZkSABlycM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XeBNSSmZkSABlycM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XeBNSSmZkSABlycM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XeBNSSmZkSABlycM .marker.cross{stroke:#333333;}#mermaid-svg-XeBNSSmZkSABlycM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XeBNSSmZkSABlycM p{margin:0;}#mermaid-svg-XeBNSSmZkSABlycM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XeBNSSmZkSABlycM .cluster-label text{fill:#333;}#mermaid-svg-XeBNSSmZkSABlycM .cluster-label span{color:#333;}#mermaid-svg-XeBNSSmZkSABlycM .cluster-label span p{background-color:transparent;}#mermaid-svg-XeBNSSmZkSABlycM .label text,#mermaid-svg-XeBNSSmZkSABlycM span{fill:#333;color:#333;}#mermaid-svg-XeBNSSmZkSABlycM .node rect,#mermaid-svg-XeBNSSmZkSABlycM .node circle,#mermaid-svg-XeBNSSmZkSABlycM .node ellipse,#mermaid-svg-XeBNSSmZkSABlycM .node polygon,#mermaid-svg-XeBNSSmZkSABlycM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XeBNSSmZkSABlycM .rough-node .label text,#mermaid-svg-XeBNSSmZkSABlycM .node .label text,#mermaid-svg-XeBNSSmZkSABlycM .image-shape .label,#mermaid-svg-XeBNSSmZkSABlycM .icon-shape .label{text-anchor:middle;}#mermaid-svg-XeBNSSmZkSABlycM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XeBNSSmZkSABlycM .rough-node .label,#mermaid-svg-XeBNSSmZkSABlycM .node .label,#mermaid-svg-XeBNSSmZkSABlycM .image-shape .label,#mermaid-svg-XeBNSSmZkSABlycM .icon-shape .label{text-align:center;}#mermaid-svg-XeBNSSmZkSABlycM .node.clickable{cursor:pointer;}#mermaid-svg-XeBNSSmZkSABlycM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XeBNSSmZkSABlycM .arrowheadPath{fill:#333333;}#mermaid-svg-XeBNSSmZkSABlycM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XeBNSSmZkSABlycM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XeBNSSmZkSABlycM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XeBNSSmZkSABlycM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XeBNSSmZkSABlycM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XeBNSSmZkSABlycM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XeBNSSmZkSABlycM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XeBNSSmZkSABlycM .cluster text{fill:#333;}#mermaid-svg-XeBNSSmZkSABlycM .cluster span{color:#333;}#mermaid-svg-XeBNSSmZkSABlycM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XeBNSSmZkSABlycM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XeBNSSmZkSABlycM rect.text{fill:none;stroke-width:0;}#mermaid-svg-XeBNSSmZkSABlycM .icon-shape,#mermaid-svg-XeBNSSmZkSABlycM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XeBNSSmZkSABlycM .icon-shape p,#mermaid-svg-XeBNSSmZkSABlycM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XeBNSSmZkSABlycM .icon-shape .label rect,#mermaid-svg-XeBNSSmZkSABlycM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XeBNSSmZkSABlycM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XeBNSSmZkSABlycM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XeBNSSmZkSABlycM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 超过 QPS
放行
无检索结果
有检索结果
超时或异常
正常返回
用户发起问答请求
Sentinel 入口限流
blockHandler 返回繁忙提示
知识库检索
返回未检索到相关内容
模型调用
fallback 返回降级答案
返回正常答案
qa_log 记录
14.1 为什么 AI 问答接口必须保护
普通业务接口通常只访问本地数据库。
例如:
text
查询知识库列表
查询文档列表
查询问答日志
这些接口虽然也需要性能优化,但它们的成本和不确定性相对可控。
AI 问答接口不同。
它至少有三个特点。
14.1.1 单次请求成本高
一次问答可能会调用:
text
Embedding 模型
聊天模型
向量数据库
MySQL
Redis
其中模型调用通常比普通 SQL 更慢,也可能产生调用费用。
如果没有控制,用户连续点击、脚本刷接口或前端重试,都可能快速消耗模型额度。
14.1.2 外部依赖不稳定
聊天模型服务可能出现:
text
网络波动
接口超时
模型限流
供应商故障
返回空内容
如果问答接口一直等待模型返回,线程会被长期占用。
请求堆积后,可能拖慢整个 knowledge-service。
14.1.3 用户需要明确反馈
当系统繁忙或模型不可用时,用户不能一直等待。
更合理的方式是快速返回明确提示。
例如:
text
当前 AI 问答服务繁忙,请稍后重试。
或者:
text
当前 AI 服务暂时不可用,请稍后重试。
前者是入口限流。
后者是模型调用降级。
这两者不能混淆。
14.2 两道稳定性保护线
Sentinel 入口保护和 AI fallback 模型调用保护的基本区分,已经在第 12 章 12.9 节讲过。本章不再重复展开概念,只强调落地时要关注的配置、默认行为和调参边界:Sentinel 管"请求能不能进入问答入口",fallback 管"模型调用失败或超时时怎么快速返回"。
在 KnowHub 中,这两道保护线分别对应下面几组配置:
text
Sentinel 入口限流
配置类:KnowledgeChatFlowRuleProperties
YAML 前缀:rag.sentinel.chat-flow
默认行为:enabled=true,resource=knowledgeChat,qps=1,便于本地演示限流
AI fallback 降级
配置类:KnowledgeChatFallbackProperties
YAML 前缀:rag.ai.chat.fallback
默认行为:enabled=true,timeout=5s,模型超时或异常时返回降级答案
两者都关闭时,问答接口仍然可以运行,但稳定性会明显下降:
text
关闭 Sentinel:入口请求不会被 QPS 保护,可能瞬时打爆模型调用链路。
关闭 fallback:模型慢或失败时,接口会一直等待或直接抛出异常。
所以第 12 章解决"代码怎么串起来",本章解决"这些参数怎么调、出问题怎么判断、生产环境怎么演进"。
下面是两道保护线的分工示意图:
#mermaid-svg-765cnCzvBBFg0sUS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-765cnCzvBBFg0sUS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-765cnCzvBBFg0sUS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-765cnCzvBBFg0sUS .error-icon{fill:#552222;}#mermaid-svg-765cnCzvBBFg0sUS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-765cnCzvBBFg0sUS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-765cnCzvBBFg0sUS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-765cnCzvBBFg0sUS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-765cnCzvBBFg0sUS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-765cnCzvBBFg0sUS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-765cnCzvBBFg0sUS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-765cnCzvBBFg0sUS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-765cnCzvBBFg0sUS .marker.cross{stroke:#333333;}#mermaid-svg-765cnCzvBBFg0sUS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-765cnCzvBBFg0sUS p{margin:0;}#mermaid-svg-765cnCzvBBFg0sUS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-765cnCzvBBFg0sUS .cluster-label text{fill:#333;}#mermaid-svg-765cnCzvBBFg0sUS .cluster-label span{color:#333;}#mermaid-svg-765cnCzvBBFg0sUS .cluster-label span p{background-color:transparent;}#mermaid-svg-765cnCzvBBFg0sUS .label text,#mermaid-svg-765cnCzvBBFg0sUS span{fill:#333;color:#333;}#mermaid-svg-765cnCzvBBFg0sUS .node rect,#mermaid-svg-765cnCzvBBFg0sUS .node circle,#mermaid-svg-765cnCzvBBFg0sUS .node ellipse,#mermaid-svg-765cnCzvBBFg0sUS .node polygon,#mermaid-svg-765cnCzvBBFg0sUS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-765cnCzvBBFg0sUS .rough-node .label text,#mermaid-svg-765cnCzvBBFg0sUS .node .label text,#mermaid-svg-765cnCzvBBFg0sUS .image-shape .label,#mermaid-svg-765cnCzvBBFg0sUS .icon-shape .label{text-anchor:middle;}#mermaid-svg-765cnCzvBBFg0sUS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-765cnCzvBBFg0sUS .rough-node .label,#mermaid-svg-765cnCzvBBFg0sUS .node .label,#mermaid-svg-765cnCzvBBFg0sUS .image-shape .label,#mermaid-svg-765cnCzvBBFg0sUS .icon-shape .label{text-align:center;}#mermaid-svg-765cnCzvBBFg0sUS .node.clickable{cursor:pointer;}#mermaid-svg-765cnCzvBBFg0sUS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-765cnCzvBBFg0sUS .arrowheadPath{fill:#333333;}#mermaid-svg-765cnCzvBBFg0sUS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-765cnCzvBBFg0sUS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-765cnCzvBBFg0sUS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-765cnCzvBBFg0sUS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-765cnCzvBBFg0sUS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-765cnCzvBBFg0sUS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-765cnCzvBBFg0sUS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-765cnCzvBBFg0sUS .cluster text{fill:#333;}#mermaid-svg-765cnCzvBBFg0sUS .cluster span{color:#333;}#mermaid-svg-765cnCzvBBFg0sUS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-765cnCzvBBFg0sUS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-765cnCzvBBFg0sUS rect.text{fill:none;stroke-width:0;}#mermaid-svg-765cnCzvBBFg0sUS .icon-shape,#mermaid-svg-765cnCzvBBFg0sUS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-765cnCzvBBFg0sUS .icon-shape p,#mermaid-svg-765cnCzvBBFg0sUS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-765cnCzvBBFg0sUS .icon-shape .label rect,#mermaid-svg-765cnCzvBBFg0sUS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-765cnCzvBBFg0sUS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-765cnCzvBBFg0sUS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-765cnCzvBBFg0sUS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第二道保护线:AI fallback 降级
第一道保护线:Sentinel 入口限流
入口繁忙提示
模型不可用提示
请求进入问答入口
QPS 判断
超过阈值 → blockHandler
模型调用
超时或异常判断
返回降级答案
用户
14.3 Sentinel 的基本概念
Sentinel 的资源名和 @SentinelResource 注解写法,第 12 章 12.3.2 已经给出。本章只保留和调参直接相关的部分:资源名必须稳定,QPS 是入口放行速率,blockHandler 是限流后的友好返回。
当前 KnowHub 用到的是最基础、最容易理解的一种:
text
QPS 限流
QPS 是 Queries Per Second,每秒请求数。
如果设置:
text
qps = 1
表示某个资源每秒最多通过 1 个请求。超过的请求会被 Sentinel 拦截,并进入 blockHandler。
14.3.1 资源名
本章只建议把资源名抽成常量,避免 Controller 注解、YAML 配置和规则初始化类中写出多个不同字符串:
java
public final class SentinelResourceNames {
private SentinelResourceNames() {
}
public static final String KNOWLEDGE_CHAT = "knowledgeChat";
}
然后在配置中保持一致:
yaml
rag:
sentinel:
chat-flow:
resource: knowledgeChat
资源名不一致,是 Sentinel 限流不生效最常见的原因。
14.3.2 blockHandler
blockHandler 的完整写法详见第 12 章 12.3.2。本章只强调两点:
text
第一,blockHandler 只处理 Sentinel 拦截,不处理模型超时。
第二,blockHandler 的方法签名必须和原方法匹配,并额外接收 BlockException。
限流返回文案应该和模型 fallback 文案区分开:
text
入口限流:当前 AI 问答服务繁忙,请稍后重试。
模型降级:当前 AI 服务暂时不可用,请稍后重试。
这能帮助前端和管理员快速判断问题发生在入口还是模型调用阶段。
14.4 KnowHub 的 Sentinel 本地规则
Sentinel 规则配置类是:
text
KnowledgeChatFlowRuleProperties
配置前缀是:
yaml
rag:
sentinel:
chat-flow:
enabled: true
resource: knowledgeChat
qps: 1
这三个配置分别表示:
text
`enabled`:是否启用问答限流
`resource`:资源名
`qps`:每秒允许通过的请求数
规则初始化类是:
text
KnowledgeChatSentinelRuleConfig
它实现了:
java
ApplicationRunner
应用启动后会执行:
java
FlowRule rule = new FlowRule();
rule.setResource(properties.getResource());
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(properties.getQps());
FlowRuleManager.loadRules(List.of(rule));
翻译成业务语言就是:
text
应用启动时,为 `knowledgeChat` 资源加载一条 QPS 限流规则。
补充 KnowledgeChatFlowRuleProperties 完整代码:
java
package com.luo.ragknowledge.qa.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 知识库问答 Sentinel 限流规则配置。
*
* 三个字段最终会映射到 Sentinel FlowRule:
* enabled 控制是否加载规则,resource 对应 FlowRule.resource,qps 对应 FlowRule.count。
*/
@ConfigurationProperties(prefix = "rag.sentinel.chat-flow")
public class KnowledgeChatFlowRuleProperties {
/**
* 是否启用问答接口限流。
*/
private boolean enabled = true;
/**
* Sentinel 资源名,必须和 @SentinelResource(value = "...") 保持一致。
*/
private String resource = "knowledgeChat";
/**
* 每秒允许通过的请求数,对应 FlowRule.count。
* 学习项目默认 1,便于手工触发限流。
*/
private int qps = 1;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getResource() {
return resource;
}
public void setResource(String resource) {
this.resource = resource;
}
public int getQps() {
return qps;
}
public void setQps(int qps) {
this.qps = qps;
}
}
补充 KnowledgeChatSentinelRuleConfig 完整代码:
java
package com.luo.ragknowledge.qa.config;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 知识库问答 Sentinel 本地限流规则初始化器。
*
* ApplicationRunner 会在 Spring Boot 启动完成后执行,
* 这样规则能在服务正式接收流量前加载完成。
*/
@Component
public class KnowledgeChatSentinelRuleConfig implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(KnowledgeChatSentinelRuleConfig.class);
private final KnowledgeChatFlowRuleProperties properties;
public KnowledgeChatSentinelRuleConfig(KnowledgeChatFlowRuleProperties properties) {
this.properties = properties;
}
@Override
public void run(ApplicationArguments args) {
if (!properties.isEnabled()) {
log.info("Sentinel 问答限流规则未启用,跳过加载");
return;
}
FlowRule rule = new FlowRule();
rule.setResource(properties.getResource());
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(properties.getQps());
FlowRuleManager.loadRules(List.of(rule));
log.info("Sentinel 问答限流规则已加载,resource={},qps={}",
properties.getResource(), properties.getQps());
}
}
14.4.1 为什么默认 1 QPS
项目默认:
text
qps = 1
这不是生产建议值。
它主要适合本地学习和演示。
因为 1 QPS 很容易触发限流,读者可以快速看到效果。
例如短时间连续点击两次问答按钮,第二次就可能被限流。
生产环境中,需要根据实际情况调整。
参考因素包括:
text
服务器线程数
模型服务额度
模型平均响应时间
pgvector 查询性能
用户并发量
预算成本
14.4.2 本地规则和生产规则
当前项目使用代码本地加载规则。
这适合学习和演示。
但生产环境通常不会把规则写死在应用启动逻辑中。
更常见的是:
text
Sentinel Dashboard
Nacos 动态数据源
配置中心动态推送
这样可以在不重启服务的情况下调整限流规则。
教材中先使用本地规则,是为了让读者更容易理解最小闭环。
下面是 Sentinel 本地规则加载流程:
#mermaid-svg-juFVA1cMIYIrtLHu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-juFVA1cMIYIrtLHu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-juFVA1cMIYIrtLHu .error-icon{fill:#552222;}#mermaid-svg-juFVA1cMIYIrtLHu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-juFVA1cMIYIrtLHu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-juFVA1cMIYIrtLHu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-juFVA1cMIYIrtLHu .marker.cross{stroke:#333333;}#mermaid-svg-juFVA1cMIYIrtLHu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-juFVA1cMIYIrtLHu p{margin:0;}#mermaid-svg-juFVA1cMIYIrtLHu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-juFVA1cMIYIrtLHu .cluster-label text{fill:#333;}#mermaid-svg-juFVA1cMIYIrtLHu .cluster-label span{color:#333;}#mermaid-svg-juFVA1cMIYIrtLHu .cluster-label span p{background-color:transparent;}#mermaid-svg-juFVA1cMIYIrtLHu .label text,#mermaid-svg-juFVA1cMIYIrtLHu span{fill:#333;color:#333;}#mermaid-svg-juFVA1cMIYIrtLHu .node rect,#mermaid-svg-juFVA1cMIYIrtLHu .node circle,#mermaid-svg-juFVA1cMIYIrtLHu .node ellipse,#mermaid-svg-juFVA1cMIYIrtLHu .node polygon,#mermaid-svg-juFVA1cMIYIrtLHu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-juFVA1cMIYIrtLHu .rough-node .label text,#mermaid-svg-juFVA1cMIYIrtLHu .node .label text,#mermaid-svg-juFVA1cMIYIrtLHu .image-shape .label,#mermaid-svg-juFVA1cMIYIrtLHu .icon-shape .label{text-anchor:middle;}#mermaid-svg-juFVA1cMIYIrtLHu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-juFVA1cMIYIrtLHu .rough-node .label,#mermaid-svg-juFVA1cMIYIrtLHu .node .label,#mermaid-svg-juFVA1cMIYIrtLHu .image-shape .label,#mermaid-svg-juFVA1cMIYIrtLHu .icon-shape .label{text-align:center;}#mermaid-svg-juFVA1cMIYIrtLHu .node.clickable{cursor:pointer;}#mermaid-svg-juFVA1cMIYIrtLHu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-juFVA1cMIYIrtLHu .arrowheadPath{fill:#333333;}#mermaid-svg-juFVA1cMIYIrtLHu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-juFVA1cMIYIrtLHu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-juFVA1cMIYIrtLHu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-juFVA1cMIYIrtLHu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-juFVA1cMIYIrtLHu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-juFVA1cMIYIrtLHu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-juFVA1cMIYIrtLHu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-juFVA1cMIYIrtLHu .cluster text{fill:#333;}#mermaid-svg-juFVA1cMIYIrtLHu .cluster span{color:#333;}#mermaid-svg-juFVA1cMIYIrtLHu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-juFVA1cMIYIrtLHu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-juFVA1cMIYIrtLHu rect.text{fill:none;stroke-width:0;}#mermaid-svg-juFVA1cMIYIrtLHu .icon-shape,#mermaid-svg-juFVA1cMIYIrtLHu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-juFVA1cMIYIrtLHu .icon-shape p,#mermaid-svg-juFVA1cMIYIrtLHu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-juFVA1cMIYIrtLHu .icon-shape .label rect,#mermaid-svg-juFVA1cMIYIrtLHu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-juFVA1cMIYIrtLHu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-juFVA1cMIYIrtLHu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-juFVA1cMIYIrtLHu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} false
true
应用启动
KnowledgeChatSentinelRuleConfig
enabled 是否为 true
跳过规则加载
创建 FlowRule
设置 resource = knowledgeChat
设置 grade = QPS
设置 count = qps
FlowRuleManager.loadRules
规则生效
14.5 AI Fallback 配置
fallback 的基础用途、YAML 配置和完整调用流程,已经在第 12 章 12.8 节讲过。本章只从调参角度补充两点:timeout 应该略高于模型正常 P95 耗时,enabled 默认建议为 true,因为外部模型服务不可控,关闭 fallback 等于把模型不稳定性直接暴露给用户。
如果模型平均 1 到 2 秒返回,timeout = 5s 通常足够。如果模型平均已经接近 5 秒,继续把 timeout 拉长并不是首选方案,应该优先检查 Prompt 是否过长、TopK 是否过大、模型服务是否稳定。
配置仍然是:
yaml
rag:
ai:
chat:
fallback:
enabled: true
timeout: 5s
answer: 当前 AI 服务暂时不可用,请稍后重试。
14.5.1 fallback 不是正常答案
完整 fallback 流程和调用代码详见第 12 章 12.8 节。本章只强调一个工程约束:降级答案必须通过 modelFallback=true 标记出来,不能当成模型正常回答,否则问答质量统计和用户界面都会失真。
14.6 模型调用超时控制
KnowledgeChatServiceImpl 中的模型调用方法是:
text
callModelWithFallback(...)
如果 fallback 没有启用,代码会直接同步调用模型:
java
String answer = callModel(prompt);
如果 fallback 启用,则走异步调用和超时控制。
核心代码是:
java
CompletableFuture<String> future = CompletableFuture.supplyAsync(
() -> callModel(prompt), knowledgeChatExecutor);
String answer = future.get(
fallbackProperties.getTimeout().toMillis(),
TimeUnit.MILLISECONDS);
这段代码可以分成两步理解。
第一步,把模型调用放到独立线程池中执行。
第二步,主线程最多等待 timeout 时间。
如果模型按时返回,就采用正常答案。
如果超时,就返回 fallback。
补充 callModelWithFallback 完整代码:
java
private ModelCallResult callModelWithFallback(Long kbId, Long userId, String prompt) {
// fallback 关闭时,同步调用模型。失败时返回 failure,由上层记录 model_success=0。
if (!fallbackProperties.isEnabled()) {
try {
String answer = callModel(prompt);
return ModelCallResult.success(answer);
} catch (Exception ex) {
String errorMessage = shortErrorMessage(ex);
log.warn("AI 模型调用失败,fallback 未启用:kbId={}, userId={}, error={}",
kbId, userId, errorMessage);
return ModelCallResult.failure(errorMessage);
}
}
CompletableFuture<String> future = CompletableFuture.supplyAsync(
() -> callModel(prompt),
knowledgeChatExecutor
);
try {
String answer = future.get(fallbackProperties.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
return ModelCallResult.success(answer);
} catch (TimeoutException ex) {
// 超时后尝试取消任务,避免后台继续占用线程。
future.cancel(true);
String errorMessage = "AI 模型调用超时,timeout=" + fallbackProperties.getTimeout();
log.warn("AI 模型调用超时,timeout={},kbId={},userId={}",
fallbackProperties.getTimeout(), kbId, userId);
return ModelCallResult.fallback(fallbackProperties.getAnswer(), errorMessage);
} catch (InterruptedException ex) {
// 恢复中断标记,这是 Java 并发中的基本习惯。
Thread.currentThread().interrupt();
String errorMessage = "AI 模型调用线程被中断";
log.warn("AI 模型调用线程被中断,kbId={},userId={}", kbId, userId);
return ModelCallResult.fallback(fallbackProperties.getAnswer(), errorMessage);
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
String errorMessage = shortErrorMessage(cause);
log.warn("AI 模型调用执行异常,kbId={},userId={},error={}",
kbId, userId, errorMessage);
return ModelCallResult.failure(errorMessage);
}
}
这段代码体现了三层判断:
text
fallback 未启用:同步调用,异常返回 failure
fallback 已启用且超时:返回 fallbackAnswer
fallback 已启用但模型内部异常:记录短错误信息,返回 failure
14.6.1 为什么需要独立线程池
补充 AsyncConfig 完整代码:
java
package com.luo.ragknowledge.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("knowledgeChatExecutor")
public Executor knowledgeChatExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("knowledge-chat-");
// 队列满时由调用线程执行,形成天然背压,避免任务被静默丢弃。
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
线程池大小不能孤立设置。它必须和模型平均耗时、期望 QPS、模型服务额度一起看。CallerRunsPolicy 的意义是:当线程池和队列都满了,调用方线程会自己执行任务,接口自然变慢,从而形成背压。
问答模型调用使用的线程池是:
text
knowledgeChatExecutor
配置在 AsyncConfig 中:
java
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("knowledge-chat-");
这表示模型调用不会无限制创建线程。
线程池有核心线程、最大线程和队列容量。
如果模型服务变慢,线程池能限制并发规模,避免系统无限堆积模型调用线程。
14.6.2 future.cancel(true) 的作用
超时时代码会执行:
java
future.cancel(true);
它表示尝试取消这次异步任务。
需要注意,取消不一定能立刻中断底层 HTTP 调用。
这取决于底层客户端是否响应中断。
但对业务接口来说,已经可以先返回降级答案,避免用户一直等待。
下面是 callModelWithFallback 的完整调用流程:
#mermaid-svg-KbxfkZCNxQv18r1y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KbxfkZCNxQv18r1y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KbxfkZCNxQv18r1y .error-icon{fill:#552222;}#mermaid-svg-KbxfkZCNxQv18r1y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KbxfkZCNxQv18r1y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KbxfkZCNxQv18r1y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KbxfkZCNxQv18r1y .marker.cross{stroke:#333333;}#mermaid-svg-KbxfkZCNxQv18r1y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KbxfkZCNxQv18r1y p{margin:0;}#mermaid-svg-KbxfkZCNxQv18r1y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KbxfkZCNxQv18r1y .cluster-label text{fill:#333;}#mermaid-svg-KbxfkZCNxQv18r1y .cluster-label span{color:#333;}#mermaid-svg-KbxfkZCNxQv18r1y .cluster-label span p{background-color:transparent;}#mermaid-svg-KbxfkZCNxQv18r1y .label text,#mermaid-svg-KbxfkZCNxQv18r1y span{fill:#333;color:#333;}#mermaid-svg-KbxfkZCNxQv18r1y .node rect,#mermaid-svg-KbxfkZCNxQv18r1y .node circle,#mermaid-svg-KbxfkZCNxQv18r1y .node ellipse,#mermaid-svg-KbxfkZCNxQv18r1y .node polygon,#mermaid-svg-KbxfkZCNxQv18r1y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KbxfkZCNxQv18r1y .rough-node .label text,#mermaid-svg-KbxfkZCNxQv18r1y .node .label text,#mermaid-svg-KbxfkZCNxQv18r1y .image-shape .label,#mermaid-svg-KbxfkZCNxQv18r1y .icon-shape .label{text-anchor:middle;}#mermaid-svg-KbxfkZCNxQv18r1y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KbxfkZCNxQv18r1y .rough-node .label,#mermaid-svg-KbxfkZCNxQv18r1y .node .label,#mermaid-svg-KbxfkZCNxQv18r1y .image-shape .label,#mermaid-svg-KbxfkZCNxQv18r1y .icon-shape .label{text-align:center;}#mermaid-svg-KbxfkZCNxQv18r1y .node.clickable{cursor:pointer;}#mermaid-svg-KbxfkZCNxQv18r1y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KbxfkZCNxQv18r1y .arrowheadPath{fill:#333333;}#mermaid-svg-KbxfkZCNxQv18r1y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KbxfkZCNxQv18r1y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KbxfkZCNxQv18r1y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KbxfkZCNxQv18r1y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KbxfkZCNxQv18r1y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KbxfkZCNxQv18r1y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KbxfkZCNxQv18r1y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KbxfkZCNxQv18r1y .cluster text{fill:#333;}#mermaid-svg-KbxfkZCNxQv18r1y .cluster span{color:#333;}#mermaid-svg-KbxfkZCNxQv18r1y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KbxfkZCNxQv18r1y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KbxfkZCNxQv18r1y rect.text{fill:none;stroke-width:0;}#mermaid-svg-KbxfkZCNxQv18r1y .icon-shape,#mermaid-svg-KbxfkZCNxQv18r1y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KbxfkZCNxQv18r1y .icon-shape p,#mermaid-svg-KbxfkZCNxQv18r1y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KbxfkZCNxQv18r1y .icon-shape .label rect,#mermaid-svg-KbxfkZCNxQv18r1y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KbxfkZCNxQv18r1y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KbxfkZCNxQv18r1y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KbxfkZCNxQv18r1y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
成功
异常
是
按时返回
TimeoutException
InterruptedException
ExecutionException
callModelWithFallback 入口
fallback 是否启用
同步调用 callModel
返回 success
返回 failure
CompletableFuture 异步调用
future.get(timeout)
返回 success
future.cancel(true)
返回 fallback
恢复中断标记
返回 fallback
shortErrorMessage
返回 failure
14.7 异常分类处理
模型调用失败并不只有一种情况。
项目中主要处理三类异常。
14.7.1 TimeoutException
超时异常表示模型在指定时间内没有返回。
项目会记录:
text
AI 模型调用超时,timeout=5s
并返回 fallback。
这种情况通常要排查:
text
模型服务是否慢
网络是否慢
Prompt 是否过长
timeout 是否设置过短
14.7.2 InterruptedException
线程被中断时,项目会先恢复中断标记:
java
Thread.currentThread().interrupt();
然后返回 fallback。
恢复中断标记是良好的 Java 并发习惯。
它告诉上层:这个线程曾经被中断过。
14.7.3 ExecutionException
ExecutionException 表示异步任务执行过程中抛出了异常。
比如:
text
模型接口返回错误
网络连接失败
认证失败
HTTP 客户端异常
项目会提取错误信息,并截断到 500 字以内:
java
shortErrorMessage(ex.getCause())
这样可以避免超长异常写入日志或数据库。
补充 shortErrorMessage 工具方法:
java
private String shortErrorMessage(Throwable cause) {
if (cause == null) {
return "未知错误";
}
String message = cause.getMessage();
if (!StringUtils.hasText(message)) {
message = cause.getClass().getSimpleName();
}
return message.length() > 500 ? message.substring(0, 500) + "..." : message;
}
这个方法不要直接返回完整堆栈。堆栈应该写应用日志,qa_log.model_error_message 只保存便于后台筛选的短错误原因。
14.8 模型状态字段
modelCalled、modelSuccess、modelFallback 三个布尔字段的组合含义,已经在第 12 章 12.12.4 节讲过。本章只强调排查视角:这三个字段要和 modelErrorMessage 一起看,才能区分"没有资料""模型失败""入口限流"和"业务异常"。
14.8.4 modelErrorMessage
modelErrorMessage 记录模型失败原因。
例如:
text
AI 模型调用超时,timeout=PT5S
或者:
text
Connection reset
项目会把错误信息截断到 500 字以内。
原因有两个:
text
第一,避免超长异常把 `qa_log` 表撑得过大。
第二,避免把第三方接口返回的冗长错误直接暴露给后台页面。
完整堆栈应该写应用日志,数据库只保存排查所需的短原因。
14.9 qa_log 中的稳定性指标
retrieval_cost_time_ms、model_cost_time_ms、cost_time_ms 三种耗时的基础含义和排查价值,已经在第 12 章 12.10 节讲过。本章只保留管理员排查时最常用的筛选方式。
14.9.4 model_success 和 model_fallback
这两个字段能快速筛选问题。
查询最近发生过 AI 降级的问答:
sql
SELECT id, user_id, kb_id, question, model_error_message, created_at
FROM qa_log
WHERE model_fallback = 1
ORDER BY created_at DESC
LIMIT 20;
查询模型调用失败但没有走 fallback 的记录:
sql
SELECT id, user_id, kb_id, question, model_error_message, created_at
FROM qa_log
WHERE model_success = 0
AND model_fallback = 0
AND model_error_message IS NOT NULL
ORDER BY created_at DESC
LIMIT 20;
查询模型耗时异常高的记录:
sql
SELECT id, user_id, kb_id, model_cost_time_ms, question
FROM qa_log
WHERE model_cost_time_ms > 5000
ORDER BY model_cost_time_ms DESC
LIMIT 20;
这就是把稳定性问题从"用户说慢"变成"数据库里可以查"的基础。
14.10 限流、降级、无检索结果和业务异常
这四类情况很容易混在一起。
必须分清楚。
14.10.1 限流
限流发生在入口。
请求没有进入完整问答流程。
典型返回:
text
当前 AI 问答服务繁忙,请稍后重试。
它对应 Sentinel 的 blockHandler。
14.10.2 降级
降级发生在模型调用阶段。
请求已经进入问答流程,也已经检索到了资料,但模型调用超时或失败。
典型返回:
text
当前 AI 服务暂时不可用,请稍后重试。
响应中:
text
modelFallback = true
14.10.3 无检索结果
无检索结果不是限流,也不是降级。
它表示知识库里没有召回相关资料。
典型返回:
text
知识库中未检索到相关内容,无法确定。
响应中:
text
modelCalled = false
modelFallback = false
14.10.4 业务异常
业务异常包括:
text
未登录
无权限
知识库不存在
参数错误
向量库未启用
这类问题应该通过统一异常处理返回业务错误。
它们和限流、模型降级不是一类问题。
下面是四类情况的区分决策图:
#mermaid-svg-vGLlWd6Z3wtSMsO7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .error-icon{fill:#552222;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .marker.cross{stroke:#333333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 p{margin:0;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster-label text{fill:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster-label span{color:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster-label span p{background-color:transparent;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .label text,#mermaid-svg-vGLlWd6Z3wtSMsO7 span{fill:#333;color:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .node rect,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node circle,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node ellipse,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node polygon,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .rough-node .label text,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node .label text,#mermaid-svg-vGLlWd6Z3wtSMsO7 .image-shape .label,#mermaid-svg-vGLlWd6Z3wtSMsO7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .rough-node .label,#mermaid-svg-vGLlWd6Z3wtSMsO7 .node .label,#mermaid-svg-vGLlWd6Z3wtSMsO7 .image-shape .label,#mermaid-svg-vGLlWd6Z3wtSMsO7 .icon-shape .label{text-align:center;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .node.clickable{cursor:pointer;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .arrowheadPath{fill:#333333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vGLlWd6Z3wtSMsO7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vGLlWd6Z3wtSMsO7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster text{fill:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .cluster span{color:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vGLlWd6Z3wtSMsO7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .icon-shape,#mermaid-svg-vGLlWd6Z3wtSMsO7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .icon-shape p,#mermaid-svg-vGLlWd6Z3wtSMsO7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .icon-shape .label rect,#mermaid-svg-vGLlWd6Z3wtSMsO7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vGLlWd6Z3wtSMsO7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vGLlWd6Z3wtSMsO7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vGLlWd6Z3wtSMsO7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
超时或失败
成功
问答请求
是否通过入口限流
限流:返回繁忙提示
是否检索到资料
无检索结果:返回未检索到内容
模型调用是否成功
降级:返回降级答案
正常答案
modelFallback = false
modelCalled = false
modelFallback = true
modelSuccess = true
14.11 参数调优
补充一个经验型调优决策表:
text
模型平均耗时 1 到 2 秒
QPS:5 到 10
timeout:3 到 5 秒
线程池 corePoolSize:QPS 的 1 到 2 倍
模型平均耗时 3 到 5 秒
QPS:2 到 5
timeout:5 到 8 秒
线程池 corePoolSize:QPS 的 2 倍
模型平均耗时超过 5 秒
不建议先盲目提高 timeout
应优先优化 Prompt 长度、TopK、chunkSize 或更换更快的模型服务
这只是学习项目的经验参考值。生产环境必须通过压测和真实 qa_log 指标确定,重点看平均耗时、P95 耗时、错误率和模型服务限额。
稳定性保护不是配置一次就永远不用管。
随着用户量、文档量和模型速度变化,参数也要调整。
14.11.1 QPS 怎么调
本地演示默认:
text
qps = 1
生产环境要结合:
text
服务器 CPU 和内存
模型服务额度
模型平均耗时
线程池大小
用户并发量
成本预算
如果模型平均 3 秒返回,QPS 配太高,很容易堆积。
如果模型额度充足、服务稳定、机器资源足够,可以适当提高。
14.11.2 timeout 怎么调
默认 timeout 是:
text
5s
如果 timeout 太短,模型可能还没来得及返回就被降级。
如果 timeout 太长,用户等待时间会变长,接口线程占用也会增加。
调参时要看:
text
模型平均耗时
P95 耗时
用户可接受等待时间
Prompt 长度
网络延迟
14.11.3 线程池怎么调
knowledgeChatExecutor 当前配置:
text
corePoolSize = 2
maxPoolSize = 4
queueCapacity = 50
线程池太小,模型调用容易排队。
线程池太大,可能同时打太多模型请求,导致外部服务限流或本机资源紧张。
队列太长,用户可能排队等待很久。
这些都需要根据实际压力测试调整。
14.11.4 Prompt 长度也会影响稳定性
补充一个具体估算。
如果配置是:
text
TopK = 5
chunkSize = 800
overlap = 100
那么每个 chunk 的有效新增内容大约是 700 字符,5 个 chunk 的上下文大约是:
text
5 * 700 = 3500 字符
再加上系统 Prompt 规则、引用编号、来源文档名和用户问题,总 Prompt 很容易达到:
text
4500 到 5000 字符
如果模型在这个长度下明显变慢,可以按下面顺序优化:
text
第一,把 TopK 从 5 降到 3。
第二,把 chunkSize 从 800 降到 500。
第三,对 context 做总长度截断,例如超过 3000 字符只取前 3000 字符。
不要只调大 timeout。timeout 变长只是让用户等更久,不会让模型变快。
Prompt 越长,模型调用通常越慢,成本也越高。
Prompt 长度受这些因素影响:
text
TopK
chunkSize
引用片段长度
Prompt 规则文本
所以限流和降级不是孤立优化。
它们和前面章节的切片、TopK、阈值都有关。
14.12 常见问题排查
14.12.1 Sentinel 限流不生效
排查顺序:
rag.sentinel.chat-flow.enabled是否为 true。@SentinelResource的 value 是否是knowledgeChat。- 配置中的
resource是否也是knowledgeChat。 KnowledgeChatSentinelRuleConfig是否执行。FlowRuleManager.loadRules(...)是否加载规则。- 请求是否真的打到了
POST /kb/{kbId}/chat。
资源名不一致是最常见问题。
14.12.2 一直被限流
排查顺序:
- QPS 是否设置太低。
- 是否本地连续快速点击。
- 前端是否自动重试。
- 是否有多个页面同时请求。
- 压测脚本是否没有限速。
本地默认 1 QPS 很容易触发限流。
14.12.3 blockHandler 不执行
排查顺序:
- 方法名是否和注解中的
blockHandler一致。 - 方法参数是否匹配原方法,并额外接收
BlockException。 - 方法是否在同一个类中。
- Sentinel 依赖是否正常。
- 资源是否真的触发限流。
14.12.4 fallback 不返回
排查顺序:
rag.ai.chat.fallback.enabled是否为 true。- 模型调用是否进入
callModelWithFallback。 - 是否有检索结果。没有检索结果不会调用模型,也不会 fallback。
- timeout 是否设置过长。
- 异常是否发生在 fallback 外层流程。
14.12.5 明明超时但接口仍然等很久
排查顺序:
timeout配置是否被正确读取。- 时间单位是否正确,例如
5s。 - 模型调用是否走了异步分支。
- fallback 是否被关闭。
- 线程池是否已经排队导致等待位置和预期不同。
14.12.6 modelFallback=true 但没有错误原因
排查:
model_error_message是否写入qa_log。shortErrorMessage是否拿到了异常 message。- 是否是 InterruptedException 或 TimeoutException。
- 数据库字段长度是否足够。
项目中错误信息会截断到 500 字,避免超长异常写入数据库。
14.12.7 降级答案被当成正常答案展示
前端应该根据:
text
modelFallback
区分正常答案和降级答案。
如果 modelFallback=true,可以用提示样式告诉用户:
text
本次 AI 服务不可用,系统返回了临时提示。
不能把降级答案当成知识库回答。
14.12.8 模型调用首次很慢但后续变快(或相反)
现象:
text
第一次问答的 model_cost_time_ms 明显高于后续请求,
或者刚启动时正常,运行一段时间后逐渐变慢。
排查顺序:
第一,确认是否是模型服务端首次加载模型导致的预热延迟。首次调用很慢很常见,可以在服务启动后主动发起一次预热请求。
第二,检查 knowledgeChatExecutor 是否被占满。重点看活跃线程数和队列深度。
第三,检查是否有慢任务长期占用线程没有释放,例如 Prompt 过长导致模型生成时间远超预期。
第四,检查 JVM 是否触发 Full GC,导致 Stop The World 延迟。
14.12.9 Sentinel 规则在生产环境重启后丢失
现象:
text
服务重启后,限流规则恢复为代码中的默认值,
之前通过 Dashboard 或动态配置调整的规则丢失。
排查顺序:
第一,确认规则加载方式是代码本地加载,还是从配置中心读取。如果是代码本地加载,重启后一定恢复默认值。
第二,确认是否配置了 Sentinel Dashboard 或 Nacos 作为动态数据源。
第三,检查动态数据源连接配置是否在服务重启后正确加载。
学习项目使用代码本地加载足够直观。生产环境建议把 Sentinel 规则迁移到配置中心管理,避免重启后规则丢失,也方便运行时调整。
14.13 本章和前面章节的关系
第 14 章建立在前面几章之上。
第 11 章的 pgvector 检索如果很慢,会影响问答接口整体耗时。
第 12 章的 Prompt 如果太长,会影响模型调用耗时。
第 13 章的 Redis owner 缓存可以减少问答前置权限校验压力。
第 14 章的 Sentinel 和 fallback,则是在这些链路外再加稳定性保护。
可以这样理解:
text
Redis:减少重复访问。
Sentinel:限制入口流量。
fallback:模型不可用时快速返回。
qa_log:记录发生了什么。
这几块组合起来,才是一个更接近企业项目的 RAG 平台。
本章小结
这一章我们讲了 Sentinel 限流与 AI 降级。
AI 问答接口成本高、耗时长、依赖外部模型服务,所以必须做稳定性保护。KnowHub 使用 Sentinel 对 knowledgeChat 资源做 QPS 限流,超过阈值时通过 chatBlockHandler 返回"当前 AI 问答服务繁忙,请稍后重试"。
Sentinel 保护的是接口入口。项目通过 KnowledgeChatSentinelRuleConfig 在应用启动时加载本地 QPS 规则,默认 1 QPS,适合本地演示。生产环境通常会把规则交给 Sentinel Dashboard 或 Nacos 动态管理。
AI fallback 保护的是模型调用阶段。项目通过 KnowledgeChatFallbackProperties 配置是否启用降级、模型最长等待时间和降级答案。KnowledgeChatServiceImpl 使用 CompletableFuture 和 knowledgeChatExecutor 调用模型,并通过 timeout 控制最长等待时间。
模型超时、线程中断或执行异常时,系统会返回降级答案,并记录 modelSuccess=false、modelFallback=true 和 modelErrorMessage。无检索结果、入口限流、模型降级和业务异常是四类不同情况,不能混为一谈。
下一章,我们会进入调用日志、异常演练与问题排查,把前面章节中的日志字段、错误场景和排查路径系统化整理出来。
本章涉及的关键类与文件
text
knowledge-service/src/main/java/.../
config/
sentinel/
KnowledgeChatFlowRuleProperties.java (Sentinel QPS 限流配置)
KnowledgeChatSentinelRuleConfig.java (启动时加载 Sentinel 规则)
async/
AsyncConfig.java (模型调用专用线程池)
fallback/
KnowledgeChatFallbackProperties.java (AI 降级配置)
qa/service/impl/
KnowledgeChatServiceImpl.java (callModelWithFallback、shortErrorMessage)
resources/
application.yml (rag.sentinel.chat-flow、rag.ai.chat.fallback 配置)
动手验证:限流与降级行为确认
步骤一,对应 14.4 节和配置前缀 rag.sentinel.chat-flow:启动 knowledge-service,查看启动日志中是否有:
text
Sentinel 问答限流规则已加载,resource=knowledgeChat,qps=1
步骤二,对应 14.3 和 14.4 节:快速连续发送两次:
http
POST /kb/{kbId}/chat
两次间隔小于 1 秒。预期第二次请求返回:
text
当前 AI 问答服务繁忙,请稍后重试。
HTTP 状态码可以是 429,也可以是项目统一业务错误码,关键是语义要明确。
步骤三,对应配置前缀 rag.sentinel.chat-flow:把 qps 临时调高到 10,或者把 enabled 改成 false,重启服务后再次连续发送两次请求。预期第二次不再被限流。
步骤四,对应 14.5 和 14.6 节,以及配置前缀 rag.ai.chat.fallback:把 fallback timeout 临时改为:
yaml
rag:
ai:
chat:
fallback:
timeout: 1s
发送一个正常问答请求。如果模型调用通常超过 1 秒,预期收到:
text
当前 AI 服务暂时不可用,请稍后重试。
并且响应中:
text
modelFallback = true
步骤五,对应 14.5 节:把 timeout 改回 5s,再次问答。预期 modelSuccess=true,不再走降级。
步骤六,对应 14.9 节:查询最近一条被降级的记录:
sql
SELECT id, model_success, model_fallback, model_error_message
FROM qa_log
ORDER BY created_at DESC
LIMIT 1;
预期:
text
model_success = 0
model_fallback = 1
model_error_message 包含"超时"或 "timeout"
步骤七,对应 14.10 节:验证无检索结果不是降级。传入一个和所有文档都无关的问题,预期返回:
text
知识库中未检索到相关内容,无法确定。
modelCalled = false
modelFallback = false
如果 Sentinel 限流不生效,优先检查 @SentinelResource 注解中的 value 和 YAML 中的 resource 是否都是 knowledgeChat。资源名不一致是最常见的问题。
思考题
-
为什么 AI 问答接口比普通查询接口更需要限流?
因为一次问答不是一次普通数据库查询,它背后包含用户鉴权、知识库归属校验、问题 Embedding、pgvector 检索、Prompt 构造、聊天模型调用、qa_log 写入等多个步骤,其中聊天模型还是外部依赖。模型调用通常比普通 SQL 更慢,也可能产生调用费用。如果没有控制,用户连续点击、脚本刷接口或前端重试,都可能快速消耗模型额度,甚至打爆模型调用链路。
-
Sentinel 的 resource 名称为什么必须和配置中的 resource 保持一致?
因为 Sentinel 是通过资源名来匹配限流规则的。
@SentinelResource注解中的 value、YAML 配置中的 resource、规则初始化类中FlowRule.setResource(...)设置的值,三者必须指向同一个字符串。只要有一处不一致,请求进入的资源名就匹配不到已加载的规则,限流就不会生效。资源名不一致是 Sentinel 限流不生效最常见的原因。 -
blockHandler的作用是什么?blockHandler是 Sentinel 限流后的友好返回方法。当请求超过 QPS 阈值被 Sentinel 拦截时,会进入blockHandler而不是抛出异常。它只处理 Sentinel 拦截,不处理模型超时。方法签名必须和原方法匹配,并额外接收BlockException。限流返回文案应该和模型 fallback 文案区分开,帮助前端和管理员快速判断问题发生在入口还是模型调用阶段。 -
本地 Sentinel 规则和生产动态规则有什么区别?
本地规则是在应用启动时通过
KnowledgeChatSentinelRuleConfig用代码加载的,规则写死在启动逻辑中,适合学习和演示,但调整规则需要重启服务。生产动态规则通常使用 Sentinel Dashboard、Nacos 动态数据源或配置中心动态推送,可以在不重启服务的情况下调整限流规则。学习项目先使用本地规则,是为了让读者更容易理解最小闭环。 -
fallback 解决的是入口请求过多,还是模型调用失败?
fallback 解决的是模型调用失败或超时。它发生在模型调用阶段,请求已经进入问答流程、也已经检索到了资料,但模型调用超时或异常时,快速返回降级答案。入口请求过多由 Sentinel 限流解决,两者是两道不同的保护线,不能混淆。
-
为什么模型调用需要 timeout?
因为聊天模型是外部依赖,可能慢、可能失败、可能限流。如果问答接口一直等待模型返回,线程会被长期占用,请求堆积后可能拖慢整个 knowledge-service。设置 timeout 后,主线程最多等待指定时间,超时就返回降级答案,避免用户一直等待,也避免线程被无限期占用。
-
CompletableFuture在本项目的模型调用中起什么作用?CompletableFuture配合独立线程池knowledgeChatExecutor实现异步调用和超时控制。CompletableFuture.supplyAsync(() -> callModel(prompt), knowledgeChatExecutor)把模型调用放到独立线程池中执行,主线程通过future.get(timeout, TimeUnit.MILLISECONDS)最多等待 timeout 时间。如果模型按时返回就采用正常答案,超时就返回 fallback。 -
modelCalled=false和modelFallback=true分别代表什么?modelCalled=false表示没有调用模型,典型场景是无检索结果------知识库里没有召回相关资料,不会调用模型,也不会 fallback。modelFallback=true表示模型调用超时或失败后走了降级,返回了降级答案。两者是不同情况:前者是没到模型调用这一步,后者是模型调用失败后的降级。 -
modelErrorMessage为什么不应该无限长?有两个原因:第一,避免超长异常把
qa_log表撑得过大;第二,避免把第三方接口返回的冗长错误直接暴露给后台页面。项目通过shortErrorMessage把错误信息截断到 500 字以内,完整堆栈写应用日志,数据库只保存便于后台筛选的短错误原因。 -
如果接口一直被限流,你会先检查哪些配置?
排查顺序:第一,QPS 是否设置太低,本地默认 1 QPS 很容易触发限流;第二,是否本地连续快速点击;第三,前端是否自动重试;第四,是否有多个页面同时请求;第五,压测脚本是否没有限速。如果确认是正常流量被限流,再结合模型平均耗时、服务器资源、模型服务额度等调整 QPS。
-
如果模型超时但没有返回降级答案,你会如何排查?
排查顺序:第一,
rag.ai.chat.fallback.enabled是否为 true,fallback 是否被关闭;第二,模型调用是否真的进入了callModelWithFallback的异步分支;第三,是否有检索结果,没有检索结果不会调用模型也不会 fallback;第四,timeout 配置是否被正确读取、时间单位是否正确;第五,异常是否发生在 fallback 外层流程,导致降级答案没有返回。 -
为什么降级答案不能当成正常知识库答案?
因为降级答案不是模型基于检索资料生成的回答,而是模型超时或失败后的临时提示。如果把它当成正常答案展示,问答质量统计和用户界面都会失真。前端应该根据
modelFallback区分正常答案和降级答案,当modelFallback=true时用提示样式告诉用户"本次 AI 服务不可用,系统返回了临时提示",不能把降级答案当成知识库回答。