返回博客列表
性能优化2026-08-2113 分钟阅读

给 LLM 快路径上保险:6 秒最坏情况是怎么被设计出来的

LLM 意图分类 · 超时预算 · 快失败重试 · 熔断器 · 双引擎降级

WeClaw_90|给 LLM 快路径上保险:6 秒最坏情况是怎么被设计出来的

系列文章第 90 篇 - LLM 意图分类 · 超时预算 · 快失败重试 · 熔断器 · 双引擎降级


📚 专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏

专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用

本文记录给一条 LLM 辅助路径设计「最坏情况」的完整过程。WeClaw 的意图识别默认走 0.1ms 的规则引擎,但支持用 LLM 增强分类精度。增强路径一旦接入网络,就有了无限延迟的可能。这篇讲我们如何用 6 秒超时、一次快失败重试、3 次失败熔断 60 秒三件套,把这条路径的最坏耗时从「不可控」收敛到「6 秒必降级」,以及一个关键的架构决策:LLM 永远只是增强,规则引擎永远在场。


👨‍💻 作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 为什么意图识别的 LLM 路径不能复用主对话的重试策略(场景差异)→ 6 秒超时预算的推导(用户感知阈值与级联效应)→ 快失败重试为什么只给 1 次(成本曲线)→ 熔断器:从「每次请求都试一次」到「坏了就别试了」→ 纯函数化解析层带来的可测试性 → 实测数据:正常 1.51s、慢响应 6.01s 精确降级。

核心结论

  1. 交互式路径里的 LLM 调用,超时不是可选项而是第一设计约束——没有超时的异步等待等于把响应时间交给第三方;
  2. 重试次数是成本决策不是可靠性决策:快路径重试 1 次的边际收益已经吃掉大部分网络抖动,第 2 次重试的收益趋近于零而延迟成本翻倍;
  3. 熔断器的真正价值是保护正常时段——故障期间停止无效的探测请求,让系统以最便宜的方式(规则引擎)维持 100% 可用。

一、同样是 LLM 调用,为什么这条路需要完全不同的保险条款

WeClaw 有两条截然不同的 LLM 对话路径,它们的容错诉求完全相反:

维度主对话路径意图识别 LLM 路径
用户预期「AI 在思考」,等 10~30 秒可接受「秒回」是默认预期
失败后果直接可见的错误,用户会重发静默降级,用户无感
有无兜底没有,LLM 就是答案本身有,0.1ms 的规则引擎随时接管
重试策略指数退避多次重试(值得等)重试越多越糟(延迟累加)

主对话的重试策略是「为了成功,值得多等」;意图路径的正确策略是「为了不拖慢主流程,宁可放弃」。如果把主对话的重试策略原样搬过来,一次 API 抖动会让用户盯着转圈的输入框等 20+ 秒——而这段时间里,一个 0.1 毫秒就能给出答案的规则引擎就在隔壁待命。

这是整个设计的第一原则:这条 LLM 路径的唯一产出是「更准的意图」,而不是「意图本身」。意图永远有保底方案。


二、三件套之一:6 秒超时预算

2.1 数字是怎么定的

超时值不是拍的,是三段约束夹出来的:

  1. 下界:正常 LLM 分类的实测耗时约 1.5s(输出一个 JSON,几十个 token),超时值必须显著大于它,否则正常请求被误杀;
  2. 上界:意图识别在流式对话里位于首 token 之前串行执行——它的耗时直接加在用户等待首字的时间上。超过 5~6 秒,用户会明显感知「卡了」;
  3. 可配置intent_llm_timeout = 6.0 写入 config/default.toml,不同模型/网络环境可自行调节。

2.2 实现:asyncio.wait_for 的边界行为

try:
    raw = await asyncio.wait_for(
        self._call_intent_llm(user_input),
        timeout=self._intent_llm_timeout,   # 6.0s,配置驱动
    )
except asyncio.TimeoutError:
    logger.warning("意图 LLM 分类超时(%.1fs),降级规则引擎", self._intent_llm_timeout)
    return None   # None 是统一降级信号

两个容易被忽略的细节:

  • 超时后任务必须被取消wait_for 会取消被等待的协程,但如果下游 client 没有正确响应 cancel(比如同步 HTTP 库包在线程里),泄漏的请求会继续占着连接池。我们用的是支持取消的异步 client,并在压测中确认超时后无连接堆积。
  • 降级信号要简单。统一返回 None,调用方只需一行 result = llm_result or rule_result——降级逻辑越薄,越不容易在异常路径上再出 bug。

三、三件套之二:max_retries = 1 的成本推导

重试几次?我们推导过收益曲线:

重试 0 次:网络瞬时抖动(DNS 解析失败、连接重置)直接降级,误伤率最高
重试 1 次:吃掉绝大部分瞬时故障,延迟成本 = 最多再等一个超时周期
重试 2 次:边际收益已很小——连续两次瞬时故障的概率本来就低,
          而最坏延迟从 6s 涨到 12s,用户感知从「卡一下」变「卡死了」

于是定 max_retries=1,且重试间隔为 0(快失败立即重试)——这条路径等不起退避。

配合超时,单次请求的最坏延迟被精确锁定

最坏 = 第一次尝试 6s(超时)+ 重试 1 次 6s(超时)

但我们做了更激进的决定:重试与超时共享总预算,而不是各自独立计时。实际最坏情况就是 6 秒,不是 12 秒。宁可少一次重试机会,也不让最坏情况翻倍——因为少重试一次的损失只是「这次用规则引擎」,而多等 6 秒的损失是「用户以为程序死了」。


四、三件套之三:熔断器——故障期间别再做无效探测

重试解决的是「偶发抖动」,但如果 LLM 服务彻底不可用(配额耗尽、模型下线、区域性故障),每个请求都要白白烧掉 6 秒再降级——故障本身不该被放大成延迟灾难

熔断器的状态机极简:

class _IntentLLMCircuit:
    FAILURE_THRESHOLD = 3     # 连续失败 3 次 → 打开
    COOLDOWN = 60.0           # 熔断 60 秒内直接跳过 LLM

    def record_failure(self):  # 超时/解析失败/空响应都计为失败
        self._failures += 1
        if self._failures >= self.FAILURE_THRESHOLD:
            self._opened_at = time.monotonic()

    def allow(self) -> bool:
        if self._opened_at is None:
            return True
        if time.monotonic() - self._opened_at >= self.COOLDOWN:
            self.reset()       # 半开:60 秒后放一次探测
            return True
        return False

几个设计取舍:

  • 阈值 3 次:1 次就熔断会把偶发抖动误判为故障;5 次以上则故障前几个请求已经各被拖了 6 秒。3 是「确认故障」与「止损速度」的平衡点。
  • 冷却 60 秒:短了起不到保护作用(频繁半开探测);长了恢复迟钝。60 秒恰好覆盖绝大多数短时故障,又不至于让用户长时间享受不到 LLM 增强的精度。
  • 失败的定义要宽:超时、网络错误、JSON 解析失败、返回空意图,全部计入。模型「响应了但答非所问」在效果上等同于故障。

五、把解析层做成纯函数:治理的隐藏收益

LLM 返回的 JSON 千奇百怪:带 markdown 代码围栏、带前后缀解释文字、键名大小写漂移、意图名不在 25 类白名单里。最初的解析代码内嵌在 async 调用链里,每种异常都要构造一次真实 LLM 响应才能测——测不了,就等于没治理

重构把它抽成纯函数:

def _parse_llm_classify_json(raw: str) -> dict | None:
    """解析 LLM 意图分类 JSON。纯函数:输入字符串,输出结构化结果或 None。"""
    text = _strip_code_fences(raw)          # 剥掉 ```json ... ```
    start, end = _find_json_bounds(text)    # 定位第一个完整 JSON 对象
    if start is None:
        return None
    data = json.loads(text[start:end])      # 只解析 JSON 段,容忍前后噪音
    intent = str(data.get("intent", "")).strip().lower()
    if intent not in INTENT_CATEGORIES:     # 白名单校验:25 类之外一律不信任
        return None
    confidence = _clamp_float(data.get("confidence"), 0.0, 1.0)
    return {"intent": intent, "confidence": confidence}

收益立竿见影:8 种畸形输入(空串、纯围栏、截断 JSON、非法意图名、confidence 越界、字符串数字……)全部用字符串字面量构造测试用例,不需要 mock 任何网络。纯函数是「可测试性」最便宜的实现——把 IO 挡在边界外,边界内全是可用字面量驱动的逻辑。


六、统一入口:三条链路的门控分级

治理完成后顺手解决了另一个结构性问题:chat、chat_stream、deferred 三条调用链各自写了一遍「要不要走 LLM」的判断逻辑。统一收口到一个入口:

async def _route_intent_detection(self, user_input: str, *, allow_llm: bool = True):
    """意图检测统一入口。allow_llm 由调用链门控决定。"""
    if allow_llm and self._intent_mode == "llm" and self._circuit.allow():
        llm_result = await self._detect_intent_with_llm(user_input)
        if llm_result is not None:
            return llm_result
    return detect_intent_with_confidence(user_input)   # 规则引擎永远在场

三条链路的门控值各不相同,且都是显式声明而非默认推断:

链路allow_llm理由
chat(非流式,定时任务/远程降级)True后台场景无首 token 压力
chat_stream(日常交互)self._intent_llm_in_stream配置子开关,默认 False
deferred(延迟补发)False(字面量)永久排除,评审决议写死

deferred 链路用字面量 False 而非变量,是刻意的:未来任何人重构这个函数签名,这个调用点都不会被误开。门控策略写在调用点而不是藏在配置里,代码即文档。


七、实测:三组场景,三组数字

基准脚本覆盖三种典型工况(n=30 取均值):

场景耗时行为
A. 规则引擎(对照组)0.1ms无网络,纯内存匹配
B. LLM 正常响应1.51s在 6s 预算内,LLM 增强生效
C. 慢响应(模拟服务抖动)6.01s精确触发超时,降级规则引擎

场景 C 的 6.01s 值得多看一眼:它证明超时不是理论值,wait_for 在真实慢响应下精确掐断,且降级路径返回的意图结果完全可用——用户最终拿到的答案只慢了 6 秒,而不是挂死。

熔断器也做过故障注入验证:连续 3 次失败后第 4 个请求直接走规则引擎(0ms LLM 耗时),60 秒冷却后第一次探测成功即恢复正常路径。


八、可复用的方法论

  1. 先问「失败了怎么办」,再问「成功了多准」。有兜底方案的增强路径,设计重心永远是降级质量而不是增强质量。
  2. 超时预算从用户感知倒推。意图识别在首 token 之前串行,它的时间就是用户的时间——6 秒上界不是技术偏好,是体验红线。
  3. 重试次数要算账。交互式路径上每多一次重试,最坏延迟线性翻倍;快失败立即重试 1 次是收益拐点。
  4. 熔断保护的是正常时段。它的价值不在故障期间省几个请求,而在让故障不转化为延迟、让恢复自动发生
  5. 把解析变成纯函数。LLM 输出的所有畸形形态,都应该能用字符串字面量测试覆盖——做不到就说明边界没切对。
  6. 门控显式化。每条链路的策略写在调用点,字面量优于变量,代码即决议记录。

九、总结

6 秒、1 次重试、3 次熔断、60 秒冷却——四个数字背后不是经验口诀,而是同一个问题的四次回答:「这条路径失败时,用户的体验损失上界是多少?」 当答案从「不可控」收敛到「最多慢 6 秒,且 60 秒内自动止损」,LLM 增强才敢真正接到交互式路径上。可靠性工程的本质不是消灭故障,而是给故障标好价格。

下期预告:《WeClaw_91|双引擎意图识别:为什么我们既写了规则引擎,又接了 LLM?》

  • 一次「配置开了但没生效」的排查,揭开 chat 与 chat_stream 的链路分工真相
  • 0.1ms 与 1.5s:四个数量级的差距如何决定了架构形态
  • 子开关灰度:让增强能力先躺在配置里,等数据说话

敬请期待!


版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。