Administrator
发布于 2025-04-12 / 1826 阅读
24

把 LLM 能力封装成可靠的企业服务

订单系统线程池被一个"智能"功能打满

三月下旬的一个下午,监控突然报警:订单履约服务的 Tomcat 线程池活跃数从平时的 40 冲到 200(最大值),接口 P99 从 180ms 涨到 12s,大量订单卡在"待履约"状态。

排查过程很快。看线程 dump,200 个线程里有 187 个卡在同一个地方:

"http-nio-8080-exec-143" #221 daemon prio=5 os_prio=31 tid=0x...
   java.lang.Thread.State: RUNNABLE
	at java.net.SocketInputStream.socketRead0(Native Method)
	at okhttp3.internal.io.RealConnection$...
	at com.xxx.ai.AddressParser.parse(AddressParser.java:47)
	at com.xxx.order.FulfillmentService.submit(FulfillmentService.java:88)

问题出在两周前上线的"智能地址解析"——用户填的收货地址不规范,我们用 LLM 把它解析成标准的省市区 + 详细地址。这个功能上线时一切正常,响应时间 600~900ms。当天下午模型供应商网络抖动,P99 涨到 15s 以上,而我们没有设置任何超时,200 个线程全部堵在等响应上。

根因:把 LLM 当普通 RPC 用了

复盘时我发现根本问题不是"忘了设超时"这么简单,而是整个设计思路错了。我们下意识地把 LLM 调用当成了跟调用用户服务、库存服务一样的东西,但它们有本质区别:

维度普通内部 RPCLLM 调用
耗时5~50ms,稳定500ms~30s,方差极大
失败率< 0.1%1%~5%(含限流、超时、内容审核)
返回值结构化、可预期非结构化,可能不合格式
幂等性查询天然幂等同样输入可能返回不同结果
成本忽略不计按 token 计费,重试要花钱

照搬 RPC 的治理方式必然出问题。我们花了两周重构了整个 LLM 调用层,下面是具体做法。

超时:三层都要设,且值不一样

重构后我们用 Resilience4j 统一管理。关键是三个超时值是不同的:

resilience4j:
  timelimiter:
    instances:
      llmAddressParser:
        timeout-duration: 3s          # 非流式:整体超时
      llmChatStream:
        timeout-duration: 60s         # 流式:允许更久,但要设上限
  circuitbreaker:
    instances:
      llmAddressParser:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 50
        failure-rate-threshold: 40    # LLM 错误率本来就高,40% 才熔断
        minimum-number-of-calls: 20
        wait-duration-in-open-state: 15s
        permitted-number-of-calls-in-half-open-state: 5
  bulkhead:
    instances:
      llmAddressParser:
        max-concurrent-calls: 60      # 关键:限制并发,保护线程池

三个值背后的考虑:

  • 业务超时 3s:地址解析在下单主链路上,超过 3s 用户就跑了。这个值是从业务倒推的,不是拍脑袋;
  • HTTP 客户端超时一定要比业务超时短:我们设的连接 1s、读取 2.5s。如果 HTTP 层不设超时,业务层超时返回了但底层连接还挂着,照样会耗尽资源;
  • 并发数限制 60:这是最关键的一条。我们按"机器核数 × 4 + 预期等待量"算的,保证即使 LLM 全部超时,也只占用 60 个线程而不是全部 200 个。这一条直接解决了线程池被打满的问题。

熔断阈值设 40% 而不是常见的 50% 或 20%,是因为 LLM 调用天然有 1%~3% 的失败率(限流、内容审核、随机超时),阈值太低会频繁误熔断。但也不能太高,40% 是我们观察了两周正常波动后定的。

重试:不是所有失败都该重试

这是我最想强调的一点。一开始我们无脑重试 3 次,结果账单涨了,而且某些场景出错了。

现在的规则是按错误类型分类

错误类型是否重试策略
连接超时 / 429 限流指数退避,最多 2 次,带 jitter
5xx 服务端错误最多 1 次,立即重试
响应超时(已发出请求)走降级,可能已计费
内容审核拦截重试也是同样结果
返回格式不合预期最多 1 次,把错误原因塞回 prompt
业务校验不通过转人工

实现上我们写了个自定义的重试判定器:

RetryConfig config = RetryConfig.custom()
    .maxAttempts(3)
    .intervalFunction(IntervalFunction.ofExponentialBackoff(
        500, 2.0, 8000))     // 500ms → 1s → 2s,上限 8s
    .retryOnException(e -> isRetryable(e))
    .build();

static boolean isRetryable(Throwable e) {
    if (e instanceof ModelRateLimitException) return true;
    if (e instanceof ModelServerException ex) return ex.getCode() >= 500;
    if (e instanceof java.net.SocketTimeoutException) return false;  // 不重试
    if (e instanceof ContentFilteredException) return false;
    return false;
}

特别说明"响应超时不重试":请求已经发出去了,供应商可能已经在处理(并且计费)。重试一次就是双倍成本,而且可能得到两个不同的结果。我们宁可降级。

还有个成本上的考虑:重试会让 token 消耗翻倍。我们加了重试次数的监控,一旦重试率超过 5% 就告警,因为这通常意味着供应商在出问题或者我们的超时设得太激进。

结果校验:LLM 的返回不能直接信

这是 LLM 服务跟普通 RPC 最大的区别——返回 200 不代表结果可用。我们做了三层校验:

第一层:结构校验

要求模型返回 JSON,用 JSON Schema 校验。Spring AI 的 BeanOutputConverter 能帮一部分,但我们发现它只保证能反序列化,不保证字段值合理。自己加了 Schema 校验:

Schema schema = JsonSchemaFactory.getInstance()
        .getSchema(addressSchemaJson);
Set<ValidationMessage> errors = schema.validate(parsedJson);
if (!errors.isEmpty()) {
    metrics.counter("llm.output.schema_error").increment();
    return ParseResult.invalid(errors);
}

实测在地址解析场景下,schema 校验失败率约 1.8%,主要出现在地址特别长或者含生僻字的情况。

第二层:业务规则校验

结构对了不代表内容对。地址解析的校验规则:

  • 省份必须在标准行政区划表内;
  • 市必须在对应省下;
  • 详细地址长度在 4~100 字符之间;
  • 不能出现"未知"、"无法解析"这类模型偷懒的占位词。

这一层抓出来的问题比 schema 层多,约 4.2%。其中最典型的是模型擅自"优化"地址——把用户写的"XX小区3期"改成"XX小区三期",或者自作主张补上一个它猜的门牌号。这类改动在快递场景下是致命的。

第三层:置信度兜底

对于实在判断不了的情况,我们要求模型返回置信度,低于 0.7 的一律转人工。这条规则上线后转人工率是 6.8%,但错误率从 3.1% 降到了 0.4%。对地址这种错了就送不到的场景,值得。

熔断降级:降级方案必须提前写好

熔断打开之后怎么办?这个问题必须在写功能的时候就回答,不能等熔断了再说。我们给地址解析准备了三级降级:

  1. 规则引擎:正则 + 行政区划词典解析,能覆盖大概 65% 的常见地址,准确率 88%。这是我们自己写的 Java 代码,零外部依赖,零成本;
  2. 返回原始地址 + 标记:规则引擎也解析不出来,就把原文存下来,标记 need_manual_review,由履约人员人工处理;
  3. 允许用户重新填写:前端提示"地址识别失败,请检查"。

降级触发时的代码:

ParseResult parseWithFallback(String rawAddress) {
    try {
        return Try.ofSupplier(
                    decorators.decorateSupplier(this::callLlm))
                .recover(throwable -> {
                    log.warn("llm fallback, reason={}", throwable.toString());
                    metrics.counter("llm.fallback",
                            "reason", throwable.getClass().getSimpleName())
                           .increment();
                    return ruleEngine.parse(rawAddress);   // 第一级降级
                })
                .get();
    } catch (Exception e) {
        return ParseResult.manual(rawAddress);             // 第二级降级
    }
}

熔断打开期间我们不是直接报错,而是全部走规则引擎。这样用户几乎无感——只是地址解析准确率会下降一些,但流程不断。

SLA 怎么定义才不骗自己

最后说一下 SLA。一开始我们报的可用性是"接口成功率 99.95%",但这个数字是骗人的——它把降级成功的请求也算成功了。我们重新定义了三个指标分开报:

指标定义目标当前
服务可用性返回了可用结果(含降级)99.95%99.98%
智能可用率LLM 成功且通过校验≥ 95%95.7%
结果准确率人工抽检的正确比例≥ 98%99.2%
P99 延迟含降级路径≤ 3s2.4s

把"智能可用率"单独拿出来报很重要。如果只看服务可用性,降级常年生效你都发现不了——系统看起来一切正常,实际上 AI 部分早就废了。我们现在给"智能可用率 < 90% 持续 10 分钟"单独配了告警。

重构后的效果

上线一个月的数据对比:

  • 因 LLM 导致的线程耗尽事故:从 2 次/月降到 0;
  • 下单主链路 P99:从 180ms(事故时 12s)稳定到 210ms;
  • LLM 调用失败对用户的影响:从直接报错变成无感降级;
  • 因为不再盲目重试,地址解析的月度成本降了 11%。

就写到这。如果哪天你也被《把 LLM 能力封装成可靠的企业服务》里同一个坑绊住,回来翻这篇,能省半小时。

参考