安全工具 & LLM 注入 & FakeLLM
昨天(Day 10)我们用「验房」把 Agent 质量变得可度量——但那套评测能几秒跑完、不花钱,靠的是几块更底层的地基。今天就把这几块地基讲透:工具永不抛异常、大模型厂商无关注入、0 外网测试基础设施、以及"业务零侵入自动记账"的技术底座 ContextVar。它们是前面所有"测试友好、厂商无关、自动记账"承诺能成立的技术前提,也是下周(D12)运行时的支撑。
本日全景:前面一切的技术底座
前面几天反复出现三句话:"工具挂了自动兜底"、"业务不直接 import anthropic,走 get_llm"、"测试走 FakeLLM 不联网"。今天就把这三件底层能力讲透——它们是整个可信底座能成立的技术前提。
@safe_tool_result
工具永不抛异常,失败返回统一 {ok, data, error}。
get_llm / set_llm_factory
厂商无关的 LLM 注入点,测试时一键换成假的。
FakeLLM
返回固定答案的假大模型,让测试可复现、零成本。
safe_tool_result:工具永不抛异常
ConnectionError——如果不处理,这个异常会一路冒泡,把整张图掀翻,其它 3 个专家的活也白干了。于是每个节点都得写一坨 try/except,代码脏、还容易漏。这就是"一个水龙头爆管淹了全楼"。回忆 Day 08 闸⑤"基础设施降级"。它的实现之一就是 @safe_tool_result 装饰器(tools/safe_result.py:38)。它把任何工具函数包成"永远返回统一结构、永不抛异常":
@safe_tool_result(fallback={"hint": "tool failed"})
async def get_metrics(service: str) -> dict:
return await call_prometheus(service) # 就算这里炸了……
# 调用方永远拿到统一结构,不用写 try/except:
result = await get_metrics(service="A")
if result["ok"]:
data = result["data"] # 成功:真实数据
else:
data = result["data"] # 失败:fallback 兜底值
err = result["error"] # "ConnectionError: ..."(带异常类名)
返回的 ToolResult 是一个 TypedDict(safe_result.py:30),三个字段固定死:
# tools/safe_result.py:30
class ToolResult(TypedDict):
ok: bool # 成功 True / 失败 False
data: Any # 成功=真实返回;失败=fallback 兜底值
error: str | None # 成功=None;失败="异常类名: 消息"
装饰器的核心就一个 wrapper,把整个调用包进 try/except(safe_result.py:54):
# tools/safe_result.py:54
@functools.wraps(fn)
async def wrapper(*args, **kwargs) -> ToolResult:
try:
if timeout:
import asyncio
data = await asyncio.wait_for(fn(*args, **kwargs), timeout=timeout) # 超时也算失败
else:
data = await fn(*args, **kwargs)
return {"ok": True, "data": data, "error": None}
except Exception as e:
logger.warning("Tool %s failed: %s", fn.__name__, e, exc_info=True)
# 带上异常类型名,方便测试 / 监控按类别聚合
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
return {"ok": False, "data": fallback, "error": err_msg}
wrapper.__safe_tool_result__ = True # 打个标记,别处可探测这个函数已被包过
try 里正常调你的工具函数,成功就返 {ok:True, data:真实结果}。except Exception 捞住任何异常(连超时都被 wait_for 转成异常一起捞),记一条 warning 日志,然后返 {ok:False, data:fallback, error:"ConnectionError: timed out"}。最后一行给函数打个 __safe_tool_result__ 标记——像贴张"已装止水阀"的贴纸,方便别的代码检查。{"ok": true, "data": {"p99": 820, ...}, "error": null}失败:Prometheus 挂了 →
{"ok": false, "data": {"hint": "tool failed"}, "error": "ConnectionError: timed out"}调用方两种情况都只写
if result["ok"],一个 try/except 都不用——止水阀替它把爆管兜住了。{ok, data, error} 后,节点里只需判 ok,代码干净、行为可预测。这也是 Day 05 里 sre-rca 的 4 个 observability 工具全都带这个装饰器的原因。error 为什么写成 f"{type(e).__name__}: {e}"(safe_result.py:66)而不是只存 str(e)?因为带上异常类名(ConnectionError / TimeoutError / ValueError)后,监控可以按"错误类别"聚合——"这周 ConnectionError 涨了 3 倍"一眼可见。只存消息文本就聚合不了。这是"为可观测性多存一点结构"的典型取舍。fallback 不传时默认是 {}(safe_result.py:49)。所以调用方在 ok=False 时拿到的 data 可能是空字典——节点代码要能接受"降级数据是空的"这种情况,别默认 data 里一定有你要的键。给关键工具显式传一个有意义的 fallback(如 {"hint": "tool failed"})是更稳的写法。get_llm:厂商无关的 LLM 注入点
这是全框架最关键的一条约定:业务代码绝不直接 import anthropic 或 ChatAnthropic(...),一律通过 get_llm() 拿模型(llm.py)。为什么?看这张对比:
生产:get_llm 返回真 Claude
llm = get_llm("sonnet")底层
_default_factory(llm.py:224)创建真 ChatAnthropic,还自动挂上计费回调。测试:一行换成假的
set_llm_factory(lambda tier: FakeLLM(...))业务代码一个字不改,get_llm 就返回假模型。不联网、不花钱、结果固定。
get_llm("sonnet"))。生产时插座背后接的是真 Claude,测试时物业把插座背后整个换成假电源(set_llm_factory(...FakeLLM)),冰箱一个螺丝都不用动。以后想换供电商(换大模型厂商)?也只改插座背后那一处工厂,全楼家电无感。这就是"依赖注入"。llm = ChatAnthropic(api_key=...)。问题立刻来了——① 测试怎么办?总不能每跑一次单测就真调一次 Claude、花钱又慢又飘;② 想换厂商?得改几十处业务代码。把"创建模型"从业务里抽出来、藏到一个可替换的工厂背后,这两个问题一起解决——这正是 get_llm 存在的动机。机制其实很轻——就是一个模块级的可替换工厂 _factory(llm.py:29)。get_llm 每次去问"当前工厂是谁":
# llm.py:29
_factory: Callable[[ModelTier], Any] | None = None # None 时用默认工厂
# llm.py:311 测试时注入假工厂
def set_llm_factory(fn):
global _factory
_factory = fn
# llm.py:359 get_llm 取模型:有自定义工厂用它,否则用默认工厂
factory = _factory or _default_factory
默认工厂 _default_factory(llm.py:224)才是真正 import anthropic 的唯一地方——全框架就这一处碰真厂商 SDK:
# llm.py:224 唯一创建真 ChatAnthropic 的地方
def _default_factory(tier="sonnet", *, cache_system=False, cache_messages=False):
from langchain_anthropic import ChatAnthropic # 只有这里 import 厂商 SDK
callbacks = _default_callbacks() # 自动挂计费回调(Day 11 L07)
if tier == "haiku":
model = os.environ.get("AI_TRUST_HAIKU_MODEL", "claude-haiku-4-5")
llm = ChatAnthropic(model=model, temperature=0.2, max_tokens=..., callbacks=callbacks)
else: # sonnet 默认
model = os.environ.get("AI_TRUST_SONNET_MODEL", "claude-sonnet-4-5")
llm = ChatAnthropic(model=model, temperature=0.2, max_tokens=8192, callbacks=callbacks)
return llm
get_llm("sonnet"),get_llm 去看 _factory 这个"当前插座接谁":生产时是 None,就走 _default_factory 造一个真 Claude(还顺手挂上计费回调);测试时 set_llm_factory(...) 把 _factory 换成返回 FakeLLM 的函数,get_llm 就返回假模型。业务代码一个字都不用改——它只认 get_llm 这个入口。连模型 ID 都从环境变量读,换个具体型号也不用动代码。get_llm 这个抽象入口,真正给什么模型由外部工厂决定(reset_llm_factory() llm.py:408 测试后还原)。它是 Day 10 "评测/测试全走 FakeLLM"能成立的根本前提,也让"换大模型厂商"变成只改 _default_factory 一处的事。AGENTS.md 把"业务直接 import anthropic"列为头号反模式——因为那等于把插座焊死在市电上。三档 tier & auto 成本降级
get_llm(tier) 的 tier 参数就是 Day 05 讲的"省钱三档"(ModelTier, llm.py:27):
sonnet
贵但强
综合/专家等难活
haiku
便宜快
分诊/质检等轻活
auto ⭐
自动选
按预算动态降级
get_llm("auto") 是 v0.6 的成本感知路由:预算充足用 Sonnet,预算剩余低于阈值(默认 10%)时自动降到 Haiku。决策在 cost/router.py 的 CostAwareRouter.resolve_tier()(router.py:108),按优先级短路:
# cost/router.py:127 resolve_tier 按优先级"短路"返回 (tier, reason)
if requested != "auto": return (requested, REASON_EXPLICIT) # 明确指定就用它
if tracker is None: return (self.default_tier, REASON_NO_TRACKER)
tenant_id = getattr(tracker, "tenant_id", None) or ""
if not tenant_id: return (self.default_tier, REASON_NO_TENANT)
try:
pct = get_pct_sync(tenant_id) # 读预算剩余百分比(60s sync cache · 不 await)
except Exception:
return (self.default_tier, REASON_FALLBACK_PCT_ERROR) # 查失败也不降级(fail-safe)
if pct < self.budget_pct_degrade_threshold: return (self.degrade_tier, REASON_BUDGET_DEGRADATION)
return (self.default_tier, REASON_DEFAULT)
把 resolve_tier 当调试器单步走一遍——4 个不同请求进来,看它命中哪条短路、最终用哪档模型:
| 请求 | 命中哪条短路 | reason | 实际 tier |
|---|---|---|---|
显式 tier="haiku" | 第 1 条:requested≠auto | explicit | haiku |
tier="auto",无 tracker | 第 2 条:tracker is None | no_tracker | default(sonnet) |
tier="auto",预算剩 40% | 都不命中 → else | default | sonnet |
tier="auto",预算剩 6% | 第 4 条:pct<10% 阈值 | budget_degradation | haiku(降级) |
auto 会一直挑最便宜的省钱」。其实相反:auto 默认给你最强的 Sonnet,只有当预算真的快烧光(剩余<10%)才"忍痛"降到 Haiku 保命。省钱是兜底策略,不是默认行为。每个 reason 都不是随手写的字符串,而是从一个 frozenset 白名单里取(router.py:63):
# cost/router.py:63 reason 只能是这几个之一
REASON_ALLOWLIST: frozenset[str] = frozenset({
REASON_EXPLICIT, REASON_NO_TRACKER, REASON_NO_TENANT,
REASON_BUDGET_DEGRADATION, REASON_DEFAULT,
REASON_FALLBACK_NO_BUDGET_MODULE, REASON_FALLBACK_PCT_ERROR,
REASON_QUOTA_DEGRADATION, REASON_RATELIMIT_BURST, # 预留未来信号
})
agent_invoke_routing_decision_total{reason=...})。监控系统里,label 每多一个不同取值就多存一条时间序列——如果有人随手 return (tier, f"budget_{tenant_id}") 把租户 ID 拼进 reason,几万个租户就是几万条序列,监控直接被撑爆(label cardinality 爆炸)。用 frozenset(不可变、防误改)锁死"合法 reason 只有这 9 个",加新 reason 必须改这里 + 改 capability spec,逼你走正规流程。__slots__(router.py:92)也是同款洁癖:锁死 Router 只能有那 3 个属性,防手滑加字段。router.py:22-26)就懂:❌ async resolve_tier + await sqlite · 在 event loop 内 deadlock。路由决策是每次 get_llm 都要走的热路径,它绝不能 await(会阻塞/死锁 LangGraph 的节点),也绝不能抛异常(一抛就掀翻主推理)。所以它做成纯 sync、读 60s 缓存、任何一步失败都 fallback 默认 tier 不降级。"路由是辅助功能,出问题时宁可不省钱,也不能拖垮主流程"。pct < threshold(router.py:152),不是 <=。所以"预算剩余正好 10%"这个临界点走默认 sonnet、不降级(源码 docstring 明确写了这条边界,router.py:125)。还有个反直觉点在下面这条 anno——prompt 缓存:同样的开头别重复付费
Critic、专家这些节点的 system prompt 很长且每次调用都一样。Anthropic 的 prompt caching 能让"重复的前缀"只算一次钱(后续命中约 9 折优惠)。get_llm("haiku", cache_system=True) 就是开启它。
实现上有个兼容性巧思,就藏在 get_llm 尾部这段 try/except 里(llm.py:359):
# llm.py:359
factory = _factory or _default_factory
try:
# 先假设工厂支持 cache 形参(默认工厂支持)
return factory(actual_tier, cache_system=cache_system, cache_messages=cache_messages)
except TypeError:
# 自定义工厂签名不支持 cache 形参(常见:测试 set_llm_factory(lambda tier: FakeLLM()))
llm = factory(actual_tier) # 退一步:只按 tier 取 llm
if cache_system or cache_messages:
return _wrap_with_cache_control(llm, cache_system=cache_system, cache_messages=cache_messages)
return llm
get_llm("haiku", cache_system=True)。get_llm 先大胆假设工厂认识 cache_system 这个参数,直接传过去(真默认工厂确实认)。可测试注入的假工厂往往是 lambda tier: FakeLLM()——它只收一个 tier,多传参数就抛 TypeError。这时 except 接住,退一步:只按 tier 取到 llm,再在外面自己包一层缓存 transform。真模型走缓存优化、假模型也不崩。那"外面包一层"包的是什么?看 _wrap_with_cache_control(llm.py:265)——它还要分两种情况:
# llm.py:280
if isinstance(llm, Runnable):
return RunnableLambda(_transform) | llm # 真 ChatAnthropic:走 LangChain 管道
return _NonRunnableCacheWrap(llm, _transform) # FakeLLM 等非 Runnable:套透明委托壳
_NonRunnableCacheWrap(llm.py:287)是个"透明壳":只拦 invoke/ainvoke 塞进 transform,其它属性全用 __getattr__ 转发给里面的真对象(llm.py:306)——所以 FakeLLM 不用被迫去继承 LangChain 的 Runnable 协议。
get_llm、budget_gate(无 tracker 静默)、sensitivity.classify(async 无 IO)里到处都是,是可信工程化的暗线。scripts/lint_cache_system.py:静态检查每处 cache_system=True 附近必须有"prompt 需 ≥1024 token 才真生效"的注释——防止有人以为开了缓存就一定省钱(system prompt 太短根本触发不了缓存)。FakeLLM 测试基础设施
代码 testing/fakes.py。三个东西支撑了"整套测试几秒跑完、0 外网、可复现":
- FakeLLM(responses=[...])(
fakes.py:31):按顺序返回预置回复。适合单个节点的单测。记录调用次数便于断言。 - SystemAwareFake(rules, default)(
fakes.py:62):按 system prompt 里的关键词分发不同回复。适合一个进程里多个节点共用一个工厂——比如 sre-rca 里 4 个专家 + 综合 + Critic 各要不同假答案,靠各自 system prompt 的关键词区分。 - install_fakes()(
fakes.py:119):一键装好"假 LLM + 假 embedder + 内存向量库",返回三件套,测试 fixture 一行搞定。
先看最简单的 FakeLLM.ainvoke(fakes.py:47)——它就是个"按次数发牌"的机器:
# testing/fakes.py:47
async def ainvoke(self, messages, **kwargs) -> FakeResponse:
self.calls.append(messages) # 记下这次被谁调了(方便断言)
if self.call_count >= len(self.responses):
self.call_count += 1
return FakeResponse(content="(no more fake responses)") # 预置回复用完的哨兵
resp = self.responses[self.call_count]
self.call_count += 1
return resp
而 SystemAwareFake 更聪明,靠 system prompt 里的关键词路由到对应回复(fakes.py:91):
# testing/fakes.py:91
async def ainvoke(self, messages, **kwargs) -> FakeResponse:
system_text = self._extract_system(messages) # 抠出 system prompt 文本(fakes.py:106)
for key, resp in self.rules.items():
if key in system_text: # 第一个命中关键词的规则胜出
self.calls.append((key, messages))
return resp
self.calls.append(("default", messages))
return self.default # 都不命中 → 兜底回复
FakeLLM 预置回复用完后不会报错,而是一直返回哨兵字符串 "(no more fake responses)"(fakes.py:51)。所以如果你的测试节点比预期多调了一次 LLM,拿到的不是异常而是这句"废话"——JSON 解析多半会失败让你察觉,但记得:看到 "(no more fake responses)" 就说明预置的回复不够用了,不是模型坏了。# 典型的 conftest.py fixture
@pytest.fixture(autouse=True)
def fake_llm():
set_llm_factory(lambda tier: SystemAwareFake({
"trace 专家": '{"trace_finding": {...}}',
"综合": '{"conclusion": {...}}',
}))
yield
reset_llm_factory() # 测试结束还原
uv run pytest 几秒就跑完 900 多个测试?就是因为它们全走 FakeLLM——不发一个真实网络请求、不花一分钱、每次结果完全一样。AGENTS.md 的收尾金句之一就是"保持 fake LLM 测试"。ContextVar:业务零侵入的自动采集
最后一块底座,也是最巧的一块。问题:一次调用里,成本要按节点归因、业务效果指标要统计——难道每个节点都手动传一个"记账本"进去?太侵入了。
框架用 Python 的 ContextVar(上下文变量,task-local)解决。在一次 invoke 的入口 bind_tracker(tracker) 绑一个记账器,之后任何深处的代码都能通过 current_tracker() 拿到它,不用层层传参:
# api/cost.py:一次 invoke 的自动记账
with bind_tracker(tracker): # ContextVar 绑定
await graph.ainvoke(...) # 图里每次真 LLM 调用,
# UsageMetadataCallbackHandler 在 on_llm_end
# 自动 current_tracker().record(tokens...)
metrics = tracker.finalize(status) # 推 3 个 Prometheus 指标
三个东西共用这套 ContextVar 范式:成本追踪(api/cost.py)、业务效果指标 record_effect()(effect.py,在"真完成一次业务动作"处记一行)、预算闸门(Day 08)。而且 LangGraph 用 asyncio task 并行跑节点时会自动 copy 上下文,所以并发累加也正确。
get_llm 推理,花了多少钱有人在背后自动记好了。FakeLLM 不走真 ChatAnthropic、不触发计费回调,所以测试也不受影响。顺带提一句可观测性一键接入:observability.py 的 setup_observability(app, service_name) 给业务 FastAPI 一行挂上 /metrics(Prometheus)+ 可选 OTel。没装 OTel 就返回一个空操作的 tracer,业务无脑写 with tracer.start_as_current_span(...) 也不会报错。
bind_tracker 装表,深处任何代码 current_tracker() 读表,用完 finalize 抄表推指标。业务节点只管"用电"(调 get_llm 推理),花了多少钱有电表在背后自动记好——账本对业务完全透明,且并发的不同户互不串表。今日小结 + 动手
🧠 今天你应该能回答
- @safe_tool_result 保证了什么?(工具永不抛异常,统一返回 {ok,data,error})
- 为什么业务不直接 import anthropic、要走 get_llm?(依赖注入,测试可换 FakeLLM、可换厂商)
- 三档 tier 和 auto 降级怎么工作?(sonnet/haiku/auto,预算<10%自动降 haiku)
- FakeLLM / SystemAwareFake 分别适合什么场景?
- ContextVar 怎么做到"业务零侵入自动记账"?
✋ 动手
# 1. 读 safe_tool_result(就 72 行,很好读)
cat packages/ai-trust-toolkit/src/ai_trust_toolkit/tools/safe_result.py
# 2. 读 get_llm / set_llm_factory
sed -n '311,370p' packages/ai-trust-toolkit/src/ai_trust_toolkit/llm.py
# 3. 读 FakeLLM 三件套
cat packages/ai-trust-toolkit/src/ai_trust_toolkit/testing/fakes.py
# 4. 读成本感知路由的优先级短路
sed -n '108,150p' packages/ai-trust-toolkit/src/ai_trust_toolkit/cost/router.py
# 5. 跑相关单测
uv run pytest packages/ai-trust-toolkit/tests/test_llm_factory.py \
packages/ai-trust-toolkit/tests/test_safe_result.py -q
gov-agents-server 怎么用"一个镜像 + ENABLED_AGENTS"跑任意 agent 组合,build_v1_router 怎么给每个 agent 自动装上鉴权/envelope/落库/限流/记账。这是"跟一次请求走全程"的主线。