服务可能表面正常但实际在批准错误决策、烧预算或切换到未测试质量;Level 5 基础设施让你看见决策、控制支出、秒级止损。
这是六阶段 LLM 系统生产成熟度模型的第 5 级。前几级让系统工作起来并保证正确性。第 5 级关乎可操作性:你能否真正日常运行它、看到它在做什么、控制它的成本、在提供商宕机时幸存、在它行为异常时秒级停止——以及底层平台是否为支撑这一切做好了准备?
传统服务出问题会返回 500。一个 LLM 系统出问题则可以快速、大规模地采取错误行动。这种不对称性就是为什么可操作性在这里不是"锦上添花"——它是区分你能运行的系统和你只能祈祷的系统的那道线。本文是关于可操作性层的具体规范:可观测性信号、成本控制、路由与故障切换、熔断开关、身份模型,以及将一切连接起来的基础设施脊柱。这是系列文章中最长的一篇,因为这是moving parts 最多的层。不过统一的理念很小:让你的模型流量经过一个统一的瓶颈点、一个 decision_id 贯穿一切、以及一个永远不知道是哪个供应商作答的领域。
标准可观测性——请求率、延迟、错误率、CPU——告诉你服务在线。它无法告诉你 Agent 是否在良好地完成工作。你需要第二层针对 AI 的信号,加上让它们可操作的 schema 和阈值。
保留你惯常的 RED 指标(Rate、Errors、Duration)用于服务。新增一个决策层,把每个 Agent 决策当作一等可测量事件来对待。以下命名遵循 Prometheus 规范(_total 计数器、_bucket 直方图),每个序列都带有 tenant_id 和 capability 作为标签(表格中省略——默认它们无处不在)。一个注意事项:直方图上按 tenant_id 的标签在租户超过几百个之后是基数炸弹——保持租户数量有界,或在那之后将按租户的汇总移到 recording rules / exemplars。
metric type key labels what it answers
--------------------------------- --------- ---------------------------------------------------------------------- -------------------------------------------------
`agent_decisions_total` counter `outcome` (auto / hitl_recommended / hitl_required / reject / abstain) volume + the auto/human split
`agent_auto_execution_ratio` gauge — % handled without a human
`agent_decision_confidence` histogram — distribution of composed confidence
`agent_decision_duration_seconds` histogram `node` latency per graph node
`guardrail_blocks_total` counter `layer` (input / output / pii), `rule` what's being blocked, where
`judge_invocations_total` counter — how often the judge ran (the ratio's denominator)
`judge_disagreements_total` counter — judge overruled the primary
`llm_tokens_total` counter `direction` (in / out), `model` token consumption
`llm_cost_usd_total` counter `model` spend, rolled up by tenant/capability
`llm_call_duration_seconds` histogram `model`, `outcome` model latency, separate from service
`shadow_eval_pass_ratio` gauge `slice` live quality on sampled prod traffic
`human_override_rate` gauge —
从中归纳出四个家族:decision(volume、split、confidence、latency)、safety(guardrail blocks、judge disagreement)、cost/perf(tokens、USD、model latency)、quality(shadow eval、overrides)。如果你只 instrument 四样东西,就选 auto_execution_ratio、guardrail_blocks_total、llm_cost_usd_total 和 human_override_rate——它们分别覆盖行为、安全、金钱和质量。
不要把所有东西都倒进一个日志流——按敏感性分层,因为其中部分数据受监管,大多数告警只需要第 1 层。
第 1 层——运营(任何地方都安全,高容量):timestamp、level、decision_id、tenant_id、capability、node、duration_ms、outcome。这就是你的告警查询的内容。
第 2 层——决策元数据(在你的信任边界内;与审计相邻的记录):model、prompt_version、composed_confidence、routing decision、judge result、guardrail actions、一个 inputs_hash,以及一份经脱敏的无 PII 摘要。
第 3 层——受监管/原始数据(加密、访问控制、永不进入通用日志存储):敏感载荷,如果你保留的话——通常你在第 2 层存储一个 hash + 脱敏摘要,完全跳过第 3 层。
规则:第 1 层和第 2 层工程师可查询;第 3 层是保险箱。一个 decision_id 贯穿所有三层——以及下面的 trace——这样你可以端到端地重建任何一个决策。
普通 trace 显示跨服务的 HTTP 请求。Agent trace 还应该跨越内部图,以便你能看到哪一步失败以及每一步耗时:
span: agent.decision attrs: decision_id, tenant_id, capability, outcome, confidence
├─ span: entry attrs: identity, auth_type
├─ span: context_load attrs: slices_loaded, cache_hit
├─ span: llm_decision attrs: model, prompt_version, tokens_in, tokens_out, cost_usd
├─ span: output_guardrail attrs: blocks[], pii_actions[]
├─ span: judge attrs: ran, model, agreed (only when sampled in)
├─ span: routing attrs: decision=auto|hitl|reject, threshold
└─ span: exit attrs: ledger_entry_id
现在"为什么决策 X 花了 4 秒 / 被拒绝了?"就是一次 trace 查询。
告警的是伤害用户或业务的东西,而不是原因。一个起始集:
alert condition (example) severity
--------------------- ------------------------------------------------------------------------------ ------------
AutoExecutionSwing `auto_execution_ratio` moves >15% vs 7-day baseline warning
GuardrailBlockSpike `rate(guardrail_blocks_total[5m])` > 3× trailing hr critical
JudgeDisagreementHigh rate(judge_disagreements_total[1h]) / rate(judge_invocations_total[1h]) > 0.15 critical
CostCeiling `increase(llm_cost_usd_total[1h])` > budget/24 warning→page
ShadowEvalDrop `shadow_eval_pass_ratio` < 0.95 critical
ModelLatencyP99 `llm_call_duration_seconds` p99 > 8s for 10m
安全和质量的告警发通知;其余的开 ticket。对于决策系统设定有意义的 SLO:decision availability(≥ 99.9% 的决策返回提案——决策失败才是真正的宕机,而不是 5xx)、quality(shadow-eval ≥ 基线 − 2%,7 天滚动)、latency(p95 在你的交互预算内排除人工时间)、cost(每 1k 决策 USD 在计划的 ±20% 以内)。
三个值得指出的可观测性反模式:所有东西一个日志流(第 3 层泄漏到可搜索日志——合规问题)、自报告的模型置信度作为指标(它未经校准;跟踪 composed confidence 并对照结果验证)、没有 decision_id(每次调查都变成考古)。
LLM 成本有一个恼人的特性:直到账单出来才看得见,而且它随你未监控的东西而增长——每次调用的 token 数、每次请求的调用数、重试、有人加进去的冗长 prompt。团队往往在已经上线之后才发现单位经济学是颠倒的。解决方案不是更便宜的模型;而是一系列让成本可见且有界的结构性控制——而且它们都挂在同一个瓶颈点上。
你无法控制你看不到的,而你看不到支出当模型调用发生在代码库的各个角落。把每次调用路由经过一个网关。那个组件就是你测量和执行的地方:
现在成本可以按租户 × 能力 × 模型归因——这就是你找出钱花在哪里的方式(通常是一个话多的能力或一个过大的提示词),而不是模糊地说"少用点 AI"。
杠杆,按收益排序
杠杆 机制 典型影响
------------------------ ------------------------------------------------------------ ----------------------------------------------------------------------
**不调用模型** 将简单/确定性场景路由到代码处理 通常是最大的收益——很多"AI 成本"其实是模型在做规则该做的事
**批处理** 一个调用处理多条记录,而非 N 次调用 更少的往返次数,每条成本更低
**缓存** 对确定性结果做记忆化(嵌入向量、重复查询) 最便宜的调用是跳过的那个
**模型选型合适** 简单决策用便宜模型,复杂决策用强模型 影响巨大——大多数流量很简单,大多数成本在高级模型上
**精简提示词** 只加载需要的上下文;删除"以防万一"的前导语 每一次调用都要支付的经常性税
选型合适值得单独强调,因为它直接影响路由(下节):如果一个便宜模型通过了评估,就把它路由过去——每次调用便宜 5–20 倍是常见的。让这笔账透明可见(cost = tokens_in/1k × price_in + tokens_out/1k × price_out),而不是靠猜。
预算、速率限制和静默倍增器
按租户控制支出和速率,且状态在多个副本间共享——无状态 worker 池无法用本地内存来强制执行每个租户的限制,因为每个副本都会允许自己那一份完整的配额:
RATE = { "default": { "tokens_per_min": 200_000, "usd_per_day": 50 } } # derive from real prices
def check(tenant_id, est_tokens):
limits = RATE_FOR(tenant_id)
# 加的是估算的 token 数,不是 1 — 用调用次数来衡量 token 预算永远不会触发
if redis.incr_window(f"tok:{tenant_id}", by=est_tokens, ttl=60) > limits["tokens_per_min"]:
raise RateLimited(tenant_id)
spent = redis.get_float(f"usd:{tenant_id}:{today()}") # written by the gateway's post-call cost path
if spent > limits["usd_per_day"]: raise BudgetExceeded(tenant_id) # 100%: 硬截停
if spent > 0.8 * limits["usd_per_day"]: alert(tenant_id, "80% of daily budget") # 80%: 警告
还要设置平台级上限——否则 N 个租户 × 每人限额没有总的上界。并且注意两个悄悄把账单放大 10 倍的因素:重试风暴(不稳定的验证反复调用模型)和无界工具循环(一个不停歇的智能体)。对两者都做上限控制,并发出 llm_retries_total{reason} 指标,这样风暴就是可见的。LLM 成本本质上并不高;本质上是未被监控的。在咽喉要道监控它,账单就会与价值成正比。
让它跑得快——而不损失质量
成本和延迟有共同的根因:一个需要几分钟的流水线通常不是因为模型慢。是一个 O(n²) 的循环、逐条记录的往返调用或串行阶段——这些一直是困扰数据流水线的性能问题,现在被包在了 LLM 外面。
规则 0:在动任何东西之前锁定质量基线。性能工作很危险,因为最快版本往往微妙地不那么正确。捕获一个基线——有代表性的数据集、当前输出、关键质量指标——然后在每次改动后重新跑。没有优化可以被接受以回归基线为代价。规则 1:profiling,不要猜。一个匹配 10,000 × 10,000 条记录的流水线"感觉"是模型限制的,但 profile 显示 90% 的时间花在一个做 100,000,000 次比较的嵌套循环里。模型从来都不是问题所在。
常见的罪魁祸首,按收益排序:
O(n²) 匹配循环 → 一次索引进哈希/键连接,然后查表:约 1 亿次比较变成约 2 万次操作。
逐条记录的模型/网络往返 → 批处理,或者对简单场景跳过模型(partition(rows, is_deterministic),简单的用规则,难的一次批处理模型调用)。这与成本中的"不调用模型"杠杆是同一个,从两个地方受益。
可以并行却串行的阶段 → 有界的并发,并发数匹配真正的限制因素(提供商速率限制、CPU、内存)——而不是无界,那样只是把瓶颈移走并超出限制。
重复计算 → 缓存确定性工作(嵌入向量、解析后的输入、参考查询)。
每次改动后,重新跑两个维度——延迟基准测试和相对基线的评估——并回滚任何导致质量回归的改动,不管它有多快:
改动 延迟 质量相对基线
hash-join (原 O(n²)) 180s → 12s = 基线 ✓
批处理模型调用 12s → 6s = 基线 ✓
并行阶段 (×8) 6s → 1.8s = 基线 ✓
模型很少是瓶颈——而"快"永远不应该是凭感觉判断它是否仍然正确。
路由、兜底和总开关
你没有一个"模型"。你有一个集群——便宜的、强的、主力的、裁判的、这家提供商的和那家提供商的——你需要一层来选对那个、在一其中一个宕机时存活、以及在它行为异常时能在几秒内关掉。所有这三个都活在同一个网关后面,而所有这三个只有在业务逻辑不关心是哪个模型回答时才有效。
按决策需求路由
条件 路由到 原因
------------------------------- --------------------------------- ----------------------------------
`is_judge` 与 primary *不同* 的模型族 独立的盲点
`stakes == low` / 高流量 便宜/小模型 大多数流量;最大的成本杠杆
模糊 / 高风险 强模型 在乎准确性的地方
长上下文 / 抽取密集型 在该任务上测量表现最好的模型 任务契合(仅在你有测量数据时)
def route(d) -> str:
if d.is_judge: return JUDGE_MODEL # 与 primary 独立
if d.stakes == "low": return CHEAP_MODEL
if d.needs_long_context: return LONG_CTX_MODEL
return CAPABLE_MODEL
# 把路由规则放在一个地方(网关),可读可测试——而不是到处散布的字符串
兜底:提供商宕机时存活
def route_chain(req) -> list[str]: # 有序的兜底链
if req.is_judge: return [JUDGE_MODEL] # 裁判不做静默兜底
if req.stakes == "low": return [CHEAP_MODEL, CAPABLE_MODEL] # 失败时向上兜
return [CAPABLE_MODEL, FALLBACK_MODEL]
def complete_with_fallback(req, deadline):
for model in route_chain(req):
if breaker[model].is_open(): # 调用前检查——跳过已知宕机的提供商
continue
remaining = deadline - now() # 单次尝试预算 vs 整体 deadline
if remaining <= 0:
break
try:
resp = call(model, req, timeout=remaining, idem_key=req.idem_key)
breaker[model].record_success()
return resp
except (Timeout, ProviderError):
breaker[model].record_failure() # 每次失败都记录 → breaker 才能打开
return route_to_human(req, reason="all_models_unavailable") # 有意降级,不要报错
让这段正确的几个不那么显而易见的点:调用前检查 breaker 并在每个捕获到的错误上记录失败(常见 bug——两者都在 except 里做意味着 breaker 永远不能主动跳过宕机的提供商)。用一个整体 deadline 加上每次尝试的超时时间——天真的替代方案是对每个模型重用固定超时,会让总延迟变成 N × timeout 并打爆上游预算。幂等性是强制的:一个超时了但实际完成了(并且写了账本条目)的 primary 不应该被重复处理。有意降级——按能力决定一个更弱的兜底答案是否可接受,还是应该路由到人工。
一条原则将路由和降级绑定在一起:对路径上的每个模型都做评估。被路由到或回退到的模型是一个不同的模型,因此质量也可能不同。你的黄金数据集应该在便宜模型和回退模型上通过——针对使用它们的能力而言——一个悄悄降低质量的回退比它所覆盖的原故障更糟糕。在账本中记录是哪个模型做出的决策(in the ledger),这样结果分析就能看到便宜模型或回退模型是否表现不佳。
一次部署需要数分钟,而你可能没有那几分钟。开关必须是每个节点都检查的运行时状态:
class Mode(Enum):
LIVE = "live" # normal
HUMAN_ONLY = "human_only" # stop auto-execute; still propose to humans
HALTED = "halted" # stop deciding entirely
def get_mode(switch, tenant_id, capability) -> Mode:
try:
raw = (switch.read(f"killswitch:{tenant_id}:{capability}") # most specific
or switch.read(f"killswitch:{tenant_id}") # whole tenant
or switch.read("killswitch:global")) # global flip lands everywhere
return Mode(raw) if raw else Mode.LIVE
except SwitchUnavailable:
return Mode.HUMAN_ONLY # fail toward safe — NOT live, NOT halted (a blip shouldn't self-DoS)
四个设计选择让它值得信赖:快速传播(用共享状态 / pub-sub 做后端,这样一次切换在几秒内落地到所有实例——30 秒的本地 TTL 不算"秒级");细粒度(按租户和按能力级别, 这样"关闭"的冲击范围与问题的冲击范围匹配);中间档(HUMAN_ONLY 保持建议但停止自动执行——通常你不需要关闭,而是需要让人回到循环中);以及fail toward safe(如果节点无法读取开关,假定保守模式)。
提前决定系统如何弯曲,这样故障不会演变成级联。断路器阻止你猛击一个已死的依赖——在毫秒级失败而不是卡在 60 秒超时后面,这正是依赖故障如何变成你的线程耗尽故障的方式。在过载下,主动卸除负载:慢下来或路由到人工,而不是崩溃。一个降级到"人工处理"的系统仍在履行它的使命。
如果这些机制只在理论上有效,它们毫无价值。针对智能体运行混沌测试:
chaos test assert
--------------------------- -----------------------------------------------------
kill the model provider decisions route to humans, not error out
flip the kill switch auto-execution stops, fast, across all instances
overload / burst sheds load / degrades, doesn't crash
restart a node mid-decision in-flight decisions recover or fail safe (idempotent)
dependency latency spike circuit breaker trips; no thread pileup
如果你没有在真实故障下演练过断路器和回退,你就不知道它们是否有效——你只是在希望。断路器是让你能安心睡觉的东西。
以上所有内容都假设有一个立足点:一种让你在不重构的情况下切换模型的架构,一种保持每个动作可归因的身份模型,以及一套规模适当的基础设施。
你发布的模型不会是开始时的那个。提供商每隔几个月就会互相超越;定价会变;一个地区或合规规则会迫使你切换。如果你的业务逻辑中充斥着供应商 SDK 导入和提供商形状的请求对象,每一个都是一次重构。解决方案是一个应用于新问题的旧想法:端口和适配器。你的领域只依赖于端口——你用自己的术语定义的接口。混乱的外部(模型提供商、数据存储、队列)存在于实现这些端口的适配器中。领域代码永远不会导入供应商 SDK。
定义一个所有模型流量都经过的网关,用你自己的词汇:
@dataclass(frozen=True)
class ChatRequest: # YOUR vocabulary — note what you deliberately DON'T expose
messages: list[dict]
schema: dict | None = None # structured output (your concept, not a provider's response_format)
max_tokens: int = 1024
# no provider-specific knobs (logit_bias, etc.) — they leak the vendor into the domain.
class LLMGateway(Protocol): # shown sync for clarity; a real gateway is async/streaming
def complete(self, request: ChatRequest) -> ChatResponse: ... # your types, not a provider's
这就是做成本计量、限流、路由、回退和断路器的同一个网关——每个横切关注点只在这里出现一次,而不是分散各处。这不是巧合:使领域可移植的 choke point 就是使系统可操作的 choke point。因为领域依赖于一个 Protocol,测试可以注入一个 fake——无网络、无开销、无波动——而适配器则有自己的集成测试来针对真实实现。
不要依赖警觉来保持边界整洁;用 linter 来强制执行。导入契约规则("领域代码不得导入供应商 SDK")在有人把供应商导入到领域代码的那一刻就让构建变红。把它和一个命名约定配对——adapters/ 下的任何东西或按提供商命名的后缀可以导入该提供商,其他都不行。大多数生态系统都有等价物(JS/TS 中的模块边界 lint、Java 中的 ArchUnit、Go 中的 depguard)。它需要一个额外的翻译层和一些前期接口设计;它换来的是单文件提供商切换、可测试的领域、以及构建永久维护的边界。然后"我们能切换模型吗?"不再是一个项目,而是一个下午的事情。
代表用户行事的智能体有一个无状态 API 没有的身份问题。一个请求以用户身份到达;智能体做推理、调用下游服务、可能运行一段时间、可能在用户离开后还在工作。在每一跳:这是带着谁的授权在运行,它被允许为这个用户、在这个租户中做这件事吗?处理不当你就制造了经典的 confused deputy 问题——智能体用自己的广泛权限去做用户做不到的事情。
两种流程,两种策略。短时同步(< ~60s):传播用户的凭证——入站 token 沿着传到下游调用,这样每个动作都以用户的精确权限运行(只有每个下游都做自己的 per-action authz 时才安全)。长时运行 / 延迟执行:你无法持有用户的 token,所以在入口节点 mint 一个短期委托授权——一个非对称签名的 token