面试知识库

9. 延迟治理、模型网关与可观测#

一句话结论:慢在模型调用次数和解码量,不在检索;两轮减法砍完之后,2026-09-20 又在模型出口上补了一层自研网关(寻址 / 断路器 / 令牌桶 / 能力门),并把 SLO 口径定死。

速背卡#

#hint展开
1检索 175ms,planner 9.5s瓶颈是带 LLM 的工具与主 loop 轮数,不是 Qdrant
2两轮减法:83.2→39.7→17.5sround2 砍 4 刀、round3 砍 5 刀,只有单平台那条能干净归因
3网关四件事:寻址/断路/桶/能力门寻址用 LiteLLM Router,另外三样自研
4断路器只认「对面挂了」429 不计、总超时不计、首 token 超时计且真掐
5双桶同生共死,预扣结算RPM+TPM 要么都扣要么都不扣;发前预扣、回来按真 usage 找平
6能力门只拦「调不了工具」查得到且 False 才拒,查不到一律放行
7SLO 分母只算 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 分钟版#

  1. 先量再砍。data/eval/tool_rt_baseline.json(2026-07-09,17 条真实 query)显示检索 p50 不到 200 ms,planner p50 9.5 s。结论是治「模型调用次数 × 每次输入与解码」。
  2. round2(2026-07-13,eb9d61b)四刀:planner 关 reasoning、收尾不再让模型复述清单、比价一步出到手价、转阶段时当场注入提示。同一条 query 83.2 s → 39.7 s,主 loop 9 → 5 轮。
  3. round3(2026-09-15)五刀:收尾文案由主模型写进入参、检索后自动跑比价与精挑、精排每批封顶 15 件、偏好注入与品类知识预取并发、docstring 7395 → 1945 字符。
  4. 减法的边界要自己说破:只有 q_backpack 三遍工具序列一致,墙钟能干净归因;q_travel / q_gift 三遍三条编排,方差比刀的收益还大。
  5. 2026-09-20 加网关层:先跑 spike 验四条判据(e1cc9e8 / f9fb447),确认 LiteLLM Router 不改 body、额外延迟 p50 +1.0 ms,才把寻址切过去;断路器、令牌桶、能力门自研。
  6. 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 偏差算进延迟里,这是已知误差,不为它上时钟同步。

机制怎么跑(一次模型调用)#

  1. app/agent/llm.py:build_model 按角色建模型;不带前缀的名字照旧走 OPENAI_*,带前缀且配过 PROVIDER_<NAME>_BASE_URL 才进 Router 路(app/agent/providers.py)。
  2. _fallback_refs(role) 取链,过 capabilities.gate_fallback_refs 剔掉「查得到且不支持 function calling」的目标;整条链全被拒就抬成 error 日志。
  3. ThrottledChatModel.__call__(app/agent/gateway.py:168)第一步 get_llm_breaker(self.model).allow()——闸放在取 slot 之前,OPEN 态占了并发位再拒等于把快速失败的收益还回去。
  4. 第二步 bucket_acquire(model, estimate_prompt_tokens(messages, tools)),同样在取 slot 之前:等令牌等的是全局配额,占着本进程的槽去等不划算。
  5. 第三步才取 GatewayThrottle.slot()(并发位 + 起点间隔),LLM_MAX_CONCURRENCY 默认 20。
  6. _call_api(gateway.py:207)每次尝试重打首 token 起点,并把本次超时收到本轮 deadline 以内;重试跑在框架内部,从外面看不见,起点不重打会让前两次失败的耗时算到第三次头上。
  7. 流式走 _first_chunk:超预算用 aclose() 真掐断——不关的话 slot 还回去了、HTTP 连接还挂着。
  8. 异常出口:429 → throttle.penalize;record_outcome(breaker, exc) 按第 2 组的三档判定;失败不结算令牌桶。
  9. 成功出口:record_outcome(breaker, None) + bucket_settle(model, reserved, usage);流式则把 slot / 记账 / 结算都交给 _stream_holding_slot。
  10. Router 路上切了 fallback 会按 _hidden_params["model_id"] 反查 deployment,报 model_fallback 事件并带上「少了哪些能力」——判据必须是 model_id,response.model 在流式与非流式下形态不同。

演进时间线#

日期提交改了什么为什么
07-13eb9d61bround2 四刀,83.2 s → 39.7 s、9 → 5 轮量出来慢在模型调用次数
09-15round3 五刀(各一个 --no-ff 合并)收尾入参直出、autopick、精排封顶 15、预取并发、docstring 瘦身机制替模型做确定的事
09-20e1cc9e8 / f9fb447LiteLLM spike:四判据 + 补测五条,全过判据先写后跑,才敢切
09-205ba2df62-1 寻址切 LiteLLM Router,出口写进模型名跨家 fallback 才成立
09-20770163f2-2 LLM 断路器一家挂了不再每次等满 60 s
09-208ea55042-3 fallback 能力门只拦「调不了工具」
09-2092a2a7d、df21fae2-4 跨副本令牌桶 + 修输入估算多副本下配额才是同一本账
09-20c869135主 loop 回 deepseek-v4.1、planner 留 qwen3.8,计价按名分档v4-flash 被下架;换主模型的实测结论
09-2130e1a414-5 request_id 随队列消息过进程API 与 worker 的日志接成一条线
09-21c55a1ff6 SLO 两条:run 成功率 + 首事件延迟先把口径定死
09-21e64038d3 两级检索缓存 L1+L2省掉重复的编码 + Qdrant 往返

可观测三层(纪律:观测挂了不许影响主链路)#

  1. 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。
  2. 指标(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。
  3. 告警(app/observability/alerts.py,lifespan 起后台轮询):每 60 s 评估工具 RT 规则(窗口 300 s、样本 ≥20 才看 P95,另有 hard_ms 绝对上限)、断路器非 CLOSED、安全事件增量;冷却 900 s,没配 webhook 就写 logger.error。只喂成功调用——熔断时的快速失败约 0 ms,混进去会在故障最严重时报「已恢复」。
  4. 跨进程串线:request_id 从 HTTP 中间件起(有 X-Request-Id 就沿用,响应头回显),随 IntentTask 过队列,worker 侧绑在 handle_task 整个外层——取消、关停掐断、重投超限这几条路走不到 run_agent,而它们恰恰最需要跨进程对账(30e1a41)。
  5. 边界:样本窗在进程内,多副本时各算各的 P95,同一次抖动会推 N 条告警;出路是挪进 Redis 或退回 Prometheus + Alertmanager。

数字与证据#

数字指什么来源状态
175.8 / 9561.3 / 2293.8 msitem_search / planner / shopping_summary p50(07-09)data/eval/tool_rt_baseline.json旧版核对过(旧基线,非现状)
83.2 s → 39.7 s;9 → 5 轮round2 同一条 queryeb9d61b 正文旧版核对过(遍数未说明)
28.3 → 17.5 s;9 → 4 次;78963 → 22483round3 q_backpack 墙钟/调用/输入 token 中位docs/plans/baseline-artifacts/latency_round3_*.json旧版核对过
36.5 → 68.7 sround3 q_gift 中位(变慢)同上旧版核对过,原因未查明
+1.0 ms / +4.0 msRouter 额外延迟 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✓
20LLM_MAX_CONCURRENCY 默认(原 4).env.example:78、92a2a7d 正文✓
3916 / 45litellm 本地模型表条数 / 其中 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/36dev92 上该弃权时 qwen3.8 / deepseek 的弃权条数c869135 正文旧版核对过
0.99 / 3.0 sSLO 目标线:run 成功率、首事件 p95metrics.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 变慢的具体原因产物里看不出:未查明。剩下的矛盾是编排一致性,不是单步开销。

坑与易混点#

  1. 两种 worker 别混:子 Agent worker 2026-09-16 已删,进程 worker(app/worker.py)还在;「多 worker 各算各的 P95」说的是后者。
  2. trace_id 与 request_id 不是一回事:前者是 Langfuse 的、worker 侧根 span 自己生成,只覆盖 run_agent 内部;后者从 HTTP 中间件起、随队列消息过进程(30e1a41)。
  3. 配了 LLM_FALLBACK_CHAIN 就别再配 LLM_FALLBACK_MODEL:两套都挂着会切两次,日志里还看不出是谁切的。
  4. 流中途断不会 fallback:200 已回、窗口关了,客户端一片都拿不到,这一轮整轮白跑。「切了 Router 就有 fallback 接住」只对首 token 前成立(f9fb447 正文)。
  5. .env.example 里的模型名是占位符(LLM_MAIN=your-main-model),c869135 说的「全部加 dashscope/ 前缀」是部署侧的 .env,示例文件没跟着改——照 .env.example 配出来的是直连路,不走 Router。

本章和别章的接口#

  • 检索缓存的召回侧细节(key 组成、断路器、singleflight)在第 4 章,本章只讲延迟收益。
  • 多副本部署、队列、credit 结算在第 10 章;令牌桶的 Redis 与队列共用同一套地址口径。
  • 记账树口径、前缀缓存命中率在第 7 章;planner 选型的完整数据在第 12 章。