9. 延迟治理、模型网关与可观测#
一句话结论:慢在模型调用次数和解码量,不在检索;两轮减法砍完之后,2026-09-20 又在模型出口上补了一层自研网关(寻址 / 断路器 / 令牌桶 / 能力门),并把 SLO 口径定死。
速背卡#
| # | hint | 展开 |
|---|---|---|
| 1 | 检索 175ms,planner 9.5s | 瓶颈是带 LLM 的工具与主 loop 轮数,不是 Qdrant |
| 2 | 两轮减法:83.2→39.7→17.5s | round2 砍 4 刀、round3 砍 5 刀,只有单平台那条能干净归因 |
| 3 | 网关四件事:寻址/断路/桶/能力门 | 寻址用 LiteLLM Router,另外三样自研 |
| 4 | 断路器只认「对面挂了」 | 429 不计、总超时不计、首 token 超时计且真掐 |
| 5 | 双桶同生共死,预扣结算 | RPM+TPM 要么都扣要么都不扣;发前预扣、回来按真 usage 找平 |
| 6 | 能力门只拦「调不了工具」 | 查得到且 False 才拒,查不到一律放行 |
| 7 | SLO 分母只算 success+failed | 用户取消、依赖降级都不进分母 |
30 秒版#
一条购物 query 的墙钟,95% 花在模型上:item_search p50 175.8 ms,planner p50 9561 ms。所以延迟治理做的是减法——砍调用次数、砍输入 token、让机制替模型做确定的事,两轮下来单平台那条 query 从 28.3 s 降到 17.5 s、模型调用 9 → 4 次。之后在模型出口补了一层网关:寻址交 LiteLLM Router,断路器、跨副本令牌桶、fallback 能力门自研,因为这三样的判据都是本仓特有的。
3 分钟版#
- 先量再砍。
data/eval/tool_rt_baseline.json(2026-07-09,17 条真实 query)显示检索 p50 不到 200 ms,plannerp50 9.5 s。结论是治「模型调用次数 × 每次输入与解码」。 - round2(2026-07-13,eb9d61b)四刀:planner 关 reasoning、收尾不再让模型复述清单、比价一步出到手价、转阶段时当场注入提示。同一条 query 83.2 s → 39.7 s,主 loop 9 → 5 轮。
- round3(2026-09-15)五刀:收尾文案由主模型写进入参、检索后自动跑比价与精挑、精排每批封顶 15 件、偏好注入与品类知识预取并发、docstring 7395 → 1945 字符。
- 减法的边界要自己说破:只有
q_backpack三遍工具序列一致,墙钟能干净归因;q_travel/q_gift三遍三条编排,方差比刀的收益还大。 - 2026-09-20 加网关层:先跑 spike 验四条判据(e1cc9e8 / f9fb447),确认 LiteLLM Router 不改 body、额外延迟 p50 +1.0 ms,才把寻址切过去;断路器、令牌桶、能力门自研。
- 2026-09-21 补观测缺口:
request_id随队列消息过进程(30e1a41),SLO 两条定口径(c55a1ff),两级检索缓存省掉重复的「编码 + 打 Qdrant」(e64038d)。
它解决什么问题#
1. 模型出口只有一个,换供应商 = 改全局 env#
- 坏法:全仓只有
OPENAI_*一个出口,一条链路上不可能同时用两家;跨家 fallback 根本不成立。不修不会报错,只是「某家挂了 = 全站挂」。 - 修法:出口信息写进模型名本身(
dashscope/qwen3.8-flash),PROVIDER_<NAME>_BASE_URL/_API_KEY给出口,LLM_FALLBACK_CHAIN给链(app/agent/providers.py)。不翻译,只换出口:没重写_call_api,只把self.client换成鸭子形状的垫片转调Router.acompletion(app/agent/router_model.py),cache_control、extra_body、tools、usage 解析一行都不用抄。 - 代价:litellm 拖进 14 个包,镜像变大;
LLM_PROVIDER_ROUTER=0是回滚开关。两条口径靠测试守着——两条路发出的 body 逐字一致、usage 必须是上游给的那份(litellm 会自己估算补一份,而 credit 按 usage 结算)。
2. 一家挂了,每次调用都要等满 60 s 才知道#
- 坏法:等满
LLM_REQUEST_TIMEOUT=60,并发在飞的请求全卡着,credit 扣了、队列堆着。 - 修法:
app/agent/llm_breaker.py,连续LLM_BREAKER_THRESHOLD=5次真实故障后快速失败,LLM_BREAKER_RECOVERY_SEC=30后放一次探测。键 =llm:<provider>/<model>,自动进all_breakers(),metrics 和 alerts 不用改就看得到。 - 难点全在「什么算故障」:429 不计(说明我们发太快,降速是
GatewayThrottle.penalize的事);总超时不计(流一直在吐只是慢,算成坏会在长回答多时误熔断);首 token 超时(LLM_FIRST_TOKEN_TIMEOUT=15)计且真掐断;5xx / 连接失败计,4xx 不计。 - 代价:配了
LLM_FALLBACK_CHAIN时它是「整条链都不行」的闸,不是「主挂了」的闸——fallback 发生在 Router 内部。某家首 token 本来就慢于 15 s 的话,今天能跑的会变失败。
3. 多副本一起跑,供应商看到的是 N 倍速率#
- 坏法:
GatewayThrottle管的是每进程并发位,副本之间各算各的,429 由此而来。 - 修法:
app/agent/token_bucket.py,RPM + TPM 两个桶放进 Redis,Lua 里原子扣。两个桶要么都扣要么都不扣——扣了 RPM 却因 TPM 不足退回,那一格就凭空漏了,这类漏账不报错,只表现成「桶没满却老是等」。TPM 是预扣 + 结算:发之前按估算输入(含工具 schema)+LLM_BUCKET_EST_OUTPUT=1024扣,响应回来按真实 usage 找平,真实超预估时允许扣成负数。失败路径不结算。 - 代价:默认关(
LLM_BUCKET_ENABLED=0);Lua 超LLM_BUCKET_LUA_TIMEOUT_MS=50即退进程内桶并进 10 s 冷却,退化期限额不按副本数分摊;等令牌满LLM_BUCKET_WAIT_MAX_SEC=30就放行并记 overflow,不抛错。
4. fallback 切过去,结果那家调不了工具#
- 坏法:计划原文是「每个 provider 一份 yaml,五列全绿才允许跳」。三条理由否掉:那张表判的是「没报 400」判不了语义生效;静态表会过期,过期的表比没有表更危险;五列全绿是错的阈值——
cache_control/ thinking 不支持只是变贵变慢,工具调用出不来才是「切了不如不切」,本仓的 Agent 没有工具就是个聊天框。 - 修法:收窄成一列的否决门(
app/agent/capabilities.py),情报源用 litellm 自己维护的模型表。查得到且supports_function_calling=False→ 拒绝;查得到且 True → 放行;查不到 → 放行,只记 warning。 - 代价:坑在
supports_function_calling查不到时静默返回 False,把 None 和 False 合并就会把正在跑的主模型判死。拒绝不在启动期炸——fallback 配错的代价是「没有备用」,不是「跑不了」。
5. 同一个检索词每轮都要重新编码 + 打 Qdrant#
- 坏法:同轮 batch 跨平台搜同一个词必然重复编码,embedding 往返是这条链上更贵的一段。
- 修法:L1 进程内 LRU + L2 Redis,包住「编码 query → Qdrant 召回」两步(
app/recall/search_cache.py)。key = 索引版本 + 检索词 + top_k + 平台 + 价格/评分过滤;空结果 TTL 60 s、正常 900 s;singleflight 两级(进程内共用 Future,跨进程 SET NX 抢锁,抢不到的轮询 L2 最多 0.5 s 就自己回源)。 - 代价:缓存的是召回结果,不是
item_search的产出——后者还要过会话级 P_t 硬排除、记忆、槽位盖章,连它们一起缓存会出现「用户刚说不要塑料,下一轮又原样端回来」,等于把一个已生效的约束静默回滚。
6. SLO 没口径,「成功率」谁都能算出想要的数#
- 坏法:四档 outcome 全塞进分母。
cancelled记成 failed = 用户越爱按停止 SLO 越差;dependency_rejected(Qdrant / OpenSearch 挂了、如实告知用户)混进分母会把一次计划内维护变成 SLO 事故,记成 success 又是自欺。 - 修法:
SLO_DENOMINATOR = {"success", "failed"}(app/observability/metrics.py:99)。原始计数留服务端(Counter + Histogram),聚合交查询侧;只有目标线是 Gauge(run_success_rate=0.99、first_event_p95_seconds=3.0)。 - 代价:首事件延迟的起点是入队时刻,跨进程只能用 wall clock,NTP 偏差算进延迟里,这是已知误差,不为它上时钟同步。
机制怎么跑(一次模型调用)#
app/agent/llm.py:build_model按角色建模型;不带前缀的名字照旧走OPENAI_*,带前缀且配过PROVIDER_<NAME>_BASE_URL才进 Router 路(app/agent/providers.py)。_fallback_refs(role)取链,过capabilities.gate_fallback_refs剔掉「查得到且不支持 function calling」的目标;整条链全被拒就抬成 error 日志。ThrottledChatModel.__call__(app/agent/gateway.py:168)第一步get_llm_breaker(self.model).allow()——闸放在取 slot 之前,OPEN 态占了并发位再拒等于把快速失败的收益还回去。- 第二步
bucket_acquire(model, estimate_prompt_tokens(messages, tools)),同样在取 slot 之前:等令牌等的是全局配额,占着本进程的槽去等不划算。 - 第三步才取
GatewayThrottle.slot()(并发位 + 起点间隔),LLM_MAX_CONCURRENCY默认 20。 _call_api(gateway.py:207)每次尝试重打首 token 起点,并把本次超时收到本轮 deadline 以内;重试跑在框架内部,从外面看不见,起点不重打会让前两次失败的耗时算到第三次头上。- 流式走
_first_chunk:超预算用aclose()真掐断——不关的话 slot 还回去了、HTTP 连接还挂着。 - 异常出口:429 →
throttle.penalize;record_outcome(breaker, exc)按第 2 组的三档判定;失败不结算令牌桶。 - 成功出口:
record_outcome(breaker, None)+bucket_settle(model, reserved, usage);流式则把 slot / 记账 / 结算都交给_stream_holding_slot。 - Router 路上切了 fallback 会按
_hidden_params["model_id"]反查 deployment,报model_fallback事件并带上「少了哪些能力」——判据必须是model_id,response.model在流式与非流式下形态不同。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 07-13 | eb9d61b | round2 四刀,83.2 s → 39.7 s、9 → 5 轮 | 量出来慢在模型调用次数 |
| 09-15 | round3 五刀(各一个 --no-ff 合并) | 收尾入参直出、autopick、精排封顶 15、预取并发、docstring 瘦身 | 机制替模型做确定的事 |
| 09-20 | e1cc9e8 / f9fb447 | LiteLLM spike:四判据 + 补测五条,全过 | 判据先写后跑,才敢切 |
| 09-20 | 5ba2df6 | 2-1 寻址切 LiteLLM Router,出口写进模型名 | 跨家 fallback 才成立 |
| 09-20 | 770163f | 2-2 LLM 断路器 | 一家挂了不再每次等满 60 s |
| 09-20 | 8ea5504 | 2-3 fallback 能力门 | 只拦「调不了工具」 |
| 09-20 | 92a2a7d、df21fae | 2-4 跨副本令牌桶 + 修输入估算 | 多副本下配额才是同一本账 |
| 09-20 | c869135 | 主 loop 回 deepseek-v4.1、planner 留 qwen3.8,计价按名分档 | v4-flash 被下架;换主模型的实测结论 |
| 09-21 | 30e1a41 | 4-5 request_id 随队列消息过进程 | API 与 worker 的日志接成一条线 |
| 09-21 | c55a1ff | 6 SLO 两条:run 成功率 + 首事件延迟 | 先把口径定死 |
| 09-21 | e64038d | 3 两级检索缓存 L1+L2 | 省掉重复的编码 + Qdrant 往返 |
可观测三层(纪律:观测挂了不许影响主链路)#
- Trace(
app/agent/tracing.py):一轮一条,turn_span在run_agent入口起根 span,把session_id(= thread_id)、user_id、提示词版本、A/B 桶号传到所有子 span。根 span 必须用 Langfuse 自己的 tracer 起——它没有gen_ai.*属性,用 AgentScope 的 tracer 起会被 Langfuse 的 span 过滤器丢掉,UI 里只剩一堆没归属的子 span。 - 指标(
app/observability/metrics.py):进程内prometheus_client暴露/metrics。除工具耗时/调用数、断路器状态、缓存事件外,新增shoppingx_llm_bucket_events_total(令牌桶 degraded / overflow)、shoppingx_run_outcome_total、shoppingx_first_event_latency_seconds、shoppingx_slo_target。 - 告警(
app/observability/alerts.py,lifespan 起后台轮询):每 60 s 评估工具 RT 规则(窗口 300 s、样本 ≥20 才看 P95,另有hard_ms绝对上限)、断路器非 CLOSED、安全事件增量;冷却 900 s,没配 webhook 就写logger.error。只喂成功调用——熔断时的快速失败约 0 ms,混进去会在故障最严重时报「已恢复」。 - 跨进程串线:
request_id从 HTTP 中间件起(有X-Request-Id就沿用,响应头回显),随IntentTask过队列,worker 侧绑在handle_task整个外层——取消、关停掐断、重投超限这几条路走不到run_agent,而它们恰恰最需要跨进程对账(30e1a41)。 - 边界:样本窗在进程内,多副本时各算各的 P95,同一次抖动会推 N 条告警;出路是挪进 Redis 或退回 Prometheus + Alertmanager。
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 175.8 / 9561.3 / 2293.8 ms | item_search / planner / shopping_summary p50(07-09) | data/eval/tool_rt_baseline.json | 旧版核对过(旧基线,非现状) |
| 83.2 s → 39.7 s;9 → 5 轮 | round2 同一条 query | eb9d61b 正文 | 旧版核对过(遍数未说明) |
| 28.3 → 17.5 s;9 → 4 次;78963 → 22483 | round3 q_backpack 墙钟/调用/输入 token 中位 | docs/plans/baseline-artifacts/latency_round3_*.json | 旧版核对过 |
| 36.5 → 68.7 s | round3 q_gift 中位(变慢) | 同上 | 旧版核对过,原因未查明 |
| +1.0 ms / +4.0 ms | Router 额外延迟 p50(单发 / 并发 50) | e1cc9e8、f9fb447 正文 | ✓ |
| 5 次 / 30 s / 15 s | 断路器阈值、恢复、首 token 预算 | llm_breaker.py:96,123-124、.env.example:109-118 | ✓ |
| 1024 / 30 s / 50 ms | 令牌桶预估输出、等待上限、Lua 超时 | .env.example:134-141 | ✓ |
| 20 | LLM_MAX_CONCURRENCY 默认(原 4) | .env.example:78、92a2a7d 正文 | ✓ |
| 3916 / 45 | litellm 本地模型表条数 / 其中 dashscope+deepseek 条数 | 8ea5504 正文 | 仅口径(未复查表) |
| 5 次 vs 23 次;$0.0031 vs $0.0156 | 主 loop 用 deepseek-v4.1 vs qwen3.8 的调用数与成本(q01_travel_set 单样本) | c869135 正文 | 仅口径(单样本) |
| 26/36 vs 1/36 | dev92 上该弃权时 qwen3.8 / deepseek 的弃权条数 | c869135 正文 | 旧版核对过 |
| 0.99 / 3.0 s | SLO 目标线:run 成功率、首事件 p95 | metrics.py:94-95 | ✓ |
| 900 s / 60 s / 0.5 s | 检索缓存正常 TTL、空结果 TTL、抢锁轮询上限 | search_cache.py:48,57、e64038d 正文 | ✓ |
| 60 s / 300 s / 20 / 900 s | 告警轮询间隔、RT 窗口、最小样本、冷却 | app/observability/alerts.py | 旧版核对过 |
追问 10 题#
Q1. 怎么确定瓶颈不在检索?
先跑工具耗时基线再看分布:item_search 60 次调用 p50 175.8 ms,planner p50 9561 ms。证据 data/eval/tool_rt_baseline.json。
Q2. ⚠ 为什么寻址用 LiteLLM,断路器和桶自研? 判据是「这一层有没有本仓特有的业务口径」。寻址没有——谁家的 base_url 配哪个 key 是通用问题。断路器要判「什么算对面挂了」、桶要判「TPM 预扣算不算工具 schema」,这些是本仓口径,抄来的实现只会长得像。证据:e1cc9e8 结论行。
Q3. ⚠ 为什么 429 不计入断路器?
429 说明我们发太快,对面好得很。拿它熔断等于自己把自己关在门外;降速是 GatewayThrottle.penalize 的事。证据 770163f 正文、llm_breaker.counts_as_failure。
Q4. ⚠ 总超时也不计,那超时怎么办? 分两档:总超时 60 s 说明流一直在吐只是慢,算成坏会在长回答多时误熔断;首 token 15 s 才计,而且要真掐断——不掐只是换个地方等满 60 s,没有快速失败的收益。
Q5. 断路器实现时踩了什么坑?
两个:① asyncio.wait_for 在这里是坏的——它靠取消 __anext__ 实现超时再看任务结局,而 AgentScope 的 _stream() 吞了 CancelledError 还会 yield 一片累计结果,于是超时静默失效、线上表现为「一次没内容的模型调用,不报错」;改成 asyncio.wait 自己判时间。② 重试跑在 ChatModelBase.__call__ 内部,首 token 预算从取 slot 起算会把前两次失败的耗时算到最后一次头上——所以每进一次 _call_api 重新打点。
Q6. 半开探测撞 429 怎么办?
record_neutral():清零等于用一次限流把前面五次 5xx 洗白,什么都不记又会让状态永远卡 HALF_OPEN、allow() 恒真(断路器静默失效)。中立 = 退回 OPEN 且不刷新 _opened_at,下次还能再探一次。
Q7. ⚠ 为什么两个桶必须同生共死? 供应商配额本来两维(RPM 与 TPM 各一条线)。只限条数,30k 输入的长请求和 500 token 的短请求同价,TPM 照样撞;只限 token,短请求能打满 RPM。扣了 RPM 却因 TPM 不足退回,那一格凭空漏了——这类漏账不报错,几百次之后才看得出来。
Q8. 令牌桶上线后出过什么事?
estimate_prompt_tokens 只认 dict 形态的 messages,而 AgentScope 把 formatter.format() 放在 _call_api 内部,闸门挂在 __call__ 上看到的还是 Msg——对真实链路恒返回 0。不报错、不告警,事后 settle 按真实 usage 找平所以总账仍对,但预扣的意义正是发之前就占住额度。修法:dict 与 Msg 都收,内容块整块序列化着数;测试也从 dict 换成 Msg(用 dict 测会让这类失效照样绿)。证据 df21fae。
Q9. 能力门为什么是否决门不是放行门?
supports_function_calling 查不到的模型静默返回 False,本仓自己的模型名多半查不到——把 None 和 False 合并会把正在跑的主模型判死。所以只拒绝「查得到且 False」,查不到放行 + warning。
Q10. ⚠ 压力题:round3 之后 q_gift 从 36.5 s 变成 68.7 s,凭什么说治理有效? 不能笼统说有效。能干净归因的只有 q_backpack(前后三遍工具序列都一致,28.3 → 17.5 s);q_travel / q_gift 前后都是三遍三条编排,输入 token 中位降了但墙钟没降,说明决定墙钟的是编排走哪条路。q_gift 变慢的具体原因产物里看不出:未查明。剩下的矛盾是编排一致性,不是单步开销。
坑与易混点#
- 两种 worker 别混:子 Agent worker 2026-09-16 已删,进程 worker(
app/worker.py)还在;「多 worker 各算各的 P95」说的是后者。 trace_id与request_id不是一回事:前者是 Langfuse 的、worker 侧根 span 自己生成,只覆盖run_agent内部;后者从 HTTP 中间件起、随队列消息过进程(30e1a41)。- 配了
LLM_FALLBACK_CHAIN就别再配LLM_FALLBACK_MODEL:两套都挂着会切两次,日志里还看不出是谁切的。 - 流中途断不会 fallback:200 已回、窗口关了,客户端一片都拿不到,这一轮整轮白跑。「切了 Router 就有 fallback 接住」只对首 token 前成立(f9fb447 正文)。
.env.example里的模型名是占位符(LLM_MAIN=your-main-model),c869135 说的「全部加dashscope/前缀」是部署侧的.env,示例文件没跟着改——照.env.example配出来的是直连路,不走 Router。
本章和别章的接口#
- 检索缓存的召回侧细节(key 组成、断路器、singleflight)在第 4 章,本章只讲延迟收益。
- 多副本部署、队列、credit 结算在第 10 章;令牌桶的 Redis 与队列共用同一套地址口径。
- 记账树口径、前缀缓存命中率在第 7 章;planner 选型的完整数据在第 12 章。