Day 11 / 共 20 天 · 第 3 周 运行时与实战

安全工具 & LLM 注入 & FakeLLM

昨天(Day 10)我们用「验房」把 Agent 质量变得可度量——但那套评测能几秒跑完、不花钱,靠的是几块更底层的地基。今天就把这几块地基讲透:工具永不抛异常、大模型厂商无关注入、0 外网测试基础设施、以及"业务零侵入自动记账"的技术底座 ContextVar。它们是前面所有"测试友好、厂商无关、自动记账"承诺能成立的技术前提,也是下周(D12)运行时的支撑。

📍 你在 20 天里的位置(可信底座 → 运行时 → 精读)
D09 记忆 D10 评测 D11 安全底座 D12 server 运行时 D13 CLI/MCP D14 精读 sre-rca D15 全家福+元 Agent
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 今天讲的是全楼的水电基础设施@safe_tool_result = 每个水龙头上的止水阀,某处爆管也不会淹全楼,只会告诉你"这路水停了、给你备用水";get_llm = 全楼统一的标准插座,家电(业务)不许直接接高压电网(import anthropic),一律插标准插座,装修/检修时把插座背后换成"假电源"(FakeLLM)家电毫不知情;三档 tier + auto = 电费紧张时自动从中央空调切成风扇(Sonnet→Haiku);ContextVar = 全楼智能电表,每户每次用电自动记进账本,不用谁拿本子手抄。记住这套"水电"画面,8 讲就串起来了。
L01

本日全景:前面一切的技术底座

前面几天反复出现三句话:"工具挂了自动兜底""业务不直接 import anthropic,走 get_llm""测试走 FakeLLM 不联网"。今天就把这三件底层能力讲透——它们是整个可信底座能成立的技术前提。

🔧

@safe_tool_result

工具永不抛异常,失败返回统一 {ok, data, error}。

🤖

get_llm / set_llm_factory

厂商无关的 LLM 注入点,测试时一键换成假的。

🧪

FakeLLM

返回固定答案的假大模型,让测试可复现、零成本。

L02

safe_tool_result:工具永不抛异常

🤔 痛点:如果工具会抛异常,会怎样?sre-rca 要同时查 Prometheus、日志、发布记录。假设 Prometheus 抖动抛了 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__ 标记——像贴张"已装止水阀"的贴纸,方便别的代码检查。
📝 举个例子:同一个 get_metrics,两种结局都不抛 成功:Prometheus 正常 → {"ok": true, "data": {"p99": 820, ...}, "error": null}
失败:Prometheus 挂了 → {"ok": false, "data": {"hint": "tool failed"}, "error": "ConnectionError: timed out"}
调用方两种情况都只写 if result["ok"]一个 try/except 都不用——止水阀替它把爆管兜住了。
节点调用 get_metrics() @safe_tool_result 止水阀:try + timeout ok=true 真实 data ok=false fallback+error
图注:无论工具成功还是炸了,出口永远是统一的 {ok,data,error}——异常绝不外泄,图不会被掀翻。
为什么强制这个约定? 因为一个 Agent 要调很多外部系统(Prometheus、日志、发布记录),任何一个抖动都不该让整个图崩。统一成 {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"})是更稳的写法。
L03

get_llm:厂商无关的 LLM 注入点

这是全框架最关键的一条约定:业务代码绝不直接 import anthropicChatAnthropic(...),一律通过 get_llm() 拿模型(llm.py)。为什么?看这张对比:

生产:get_llm 返回真 Claude
llm = get_llm("sonnet")
底层 _default_factoryllm.py:224)创建真 ChatAnthropic,还自动挂上计费回调。
测试:一行换成假的
set_llm_factory(lambda tier: FakeLLM(...))
业务代码一个字不改,get_llm 就返回假模型。不联网、不花钱、结果固定。
💡 本质:get_llm 就是全楼那个"标准插座"你家买冰箱(写业务节点)时,从不关心插座背后是市电、太阳能还是发电机——你只管插进标准插座(get_llm("sonnet"))。生产时插座背后接的是真 Claude,测试时物业把插座背后整个换成假电源(set_llm_factory(...FakeLLM)),冰箱一个螺丝都不用动。以后想换供电商(换大模型厂商)?也只改插座背后那一处工厂,全楼家电无感。这就是"依赖注入"。
🛠 如果让你自己实现,你会怎么写?朴素做法:业务里直接 llm = ChatAnthropic(api_key=...)。问题立刻来了——① 测试怎么办?总不能每跑一次单测就真调一次 Claude、花钱又慢又飘;② 想换厂商?得改几十处业务代码。把"创建模型"从业务里抽出来、藏到一个可替换的工厂背后,这两个问题一起解决——这正是 get_llm 存在的动机。

机制其实很轻——就是一个模块级的可替换工厂 _factoryllm.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_factoryllm.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"列为头号反模式——因为那等于把插座焊死在市电上。
L04

三档 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.pyCostAwareRouter.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(requested) requested≠auto ? → explicit tracker/tenant 空 ? → 默认 sonnet pct < 10% ? → 降级 haiku 都不命中 → 默认 sonnet(强模型) 命中→立即返回 命中→立即返回 命中→立即返回 否↓ 否↓ 否↓ "短路":从上往下,第一个命中的分支立即 return,后面不再判
图注:resolve_tier 的"优先级短路"控制流——越靠上优先级越高,命中即返回。

resolve_tier 当调试器单步走一遍——4 个不同请求进来,看它命中哪条短路、最终用哪档模型:

请求命中哪条短路reason实际 tier
显式 tier="haiku"第 1 条:requested≠autoexplicithaiku
tier="auto",无 tracker第 2 条:tracker is Noneno_trackerdefault(sonnet)
tier="auto",预算剩 40%都不命中 → elsedefaultsonnet
tier="auto",预算剩 6%第 4 条:pct<10% 阈值budget_degradationhaiku(降级)
⚠️ 小白常误以为「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,  # 预留未来信号
})
💡 设计取舍②:为什么 reason 要用 frozenset 白名单?因为这个 reason 会当作 Prometheus 指标的 labelagent_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 个属性,防手滑加字段。
💡 设计取舍③:为什么 resolve_tier 全程 sync + try/except 不抛?看模块顶部的反模式清单(router.py:22-26)就懂:❌ async resolve_tier + await sqlite · 在 event loop 内 deadlock。路由决策是每次 get_llm 都要走的热路径,它绝不能 await(会阻塞/死锁 LangGraph 的节点),也绝不能抛异常(一抛就掀翻主推理)。所以它做成纯 sync、读 60s 缓存、任何一步失败都 fallback 默认 tier 不降级"路由是辅助功能,出问题时宁可不省钱,也不能拖垮主流程"
⚠️ 边界/易错点:降级判定是严格小于 pct < thresholdrouter.py:152),不是 <=。所以"预算剩余正好 10%"这个临界点走默认 sonnet、不降级(源码 docstring 明确写了这条边界,router.py:125)。还有个反直觉点在下面这条 anno——
L05

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_controlllm.py:265)——它还要分两种情况:

# llm.py:280
if isinstance(llm, Runnable):
    return RunnableLambda(_transform) | llm     # 真 ChatAnthropic:走 LangChain 管道
return _NonRunnableCacheWrap(llm, _transform)   # FakeLLM 等非 Runnable:套透明委托壳

_NonRunnableCacheWrapllm.py:287)是个"透明壳":只拦 invoke/ainvoke 塞进 transform,其它属性全用 __getattr__ 转发给里面的真对象llm.py:306)——所以 FakeLLM 不用被迫去继承 LangChain 的 Runnable 协议。

为什么这个兼容这么重要? 因为同一份业务代码要同时服务"真 Claude(要缓存优化)"和"FakeLLM(不懂缓存)"两种场景。框架用"先试传参、失败降级"的方式让两者都能跑——这类"为可测试性做的隐藏兼容",在 get_llmbudget_gate(无 tracker 静默)、sensitivity.classify(async 无 IO)里到处都是,是可信工程化的暗线。
仓库还有 scripts/lint_cache_system.py:静态检查每处 cache_system=True 附近必须有"prompt 需 ≥1024 token 才真生效"的注释——防止有人以为开了缓存就一定省钱(system prompt 太短根本触发不了缓存)。
L06

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.ainvokefakes.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                               # 都不命中 → 兜底回复
一个进程里 6 个节点共用一个 SystemAwareFake 6 次 LLM 调用 system:"Trace 专家…" system:"Metric 专家…" system:"最终综合…" system:"质检员…" …各自 prompt 不同 关键词匹配 rules 字典 "Trace 专家" → trace 假答案 "Metric 专家" → metric 假答案 "最终综合" → hypotheses 假答案 "质检员" → critique 假答案 都不中 → default 对号入座 各拿各的假答案
图注:SystemAwareFake 用 system prompt 关键词把一个假工厂"分流"给多个节点,一次 RCA 里 4 专家+综合+Critic 各取所需。
⚠️ 边界/易错点: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()      # 测试结束还原
回扣 Day 02:为什么 uv run pytest 几秒就跑完 900 多个测试?就是因为它们全走 FakeLLM——不发一个真实网络请求、不花一分钱、每次结果完全一样。AGENTS.md 的收尾金句之一就是"保持 fake LLM 测试"。
L07

ContextVar:业务零侵入的自动采集

最后一块底座,也是最巧的一块。问题:一次调用里,成本要按节点归因、业务效果指标要统计——难道每个节点都手动传一个"记账本"进去?太侵入了。

框架用 Python 的 ContextVar(上下文变量,task-local)解决。在一次 invoke 的入口 bind_tracker(tracker) 绑一个记账器,之后任何深处的代码都能通过 current_tracker() 拿到它,不用层层传参:

invoke 入口bind_tracker 图跑各节点 LLM 回调on_llm_end current_tracker()自动 record finalize推 Prometheus
# 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 上下文,所以并发累加也正确。

ContextVar 是什么? 可以理解成"当前这次调用链专属的全局变量"——同一次 invoke 里到处都能读到,但不同 invoke 之间互不干扰(哪怕并发)。它让"记账"这件横切的事对业务完全透明:业务节点只管调 get_llm 推理,花了多少钱有人在背后自动记好了。FakeLLM 不走真 ChatAnthropic、不触发计费回调,所以测试也不受影响。

顺带提一句可观测性一键接入observability.pysetup_observability(app, service_name) 给业务 FastAPI 一行挂上 /metrics(Prometheus)+ 可选 OTel。没装 OTel 就返回一个空操作的 tracer,业务无脑写 with tracer.start_as_current_span(...) 也不会报错。

🎯 一句话记住 ContextVar它就是全楼智能电表:入口 bind_tracker 装表,深处任何代码 current_tracker() 读表,用完 finalize 抄表推指标。业务节点只管"用电"(调 get_llm 推理),花了多少钱有电表在背后自动记好——账本对业务完全透明,且并发的不同户互不串表。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • @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
明天预告 · Day 12:底座全部讲完!Day 12 进入"运行时"——gov-agents-server 怎么用"一个镜像 + ENABLED_AGENTS"跑任意 agent 组合,build_v1_router 怎么给每个 agent 自动装上鉴权/envelope/落库/限流/记账。这是"跟一次请求走全程"的主线。
← Day 10 评测 Day 12 · gov-agents-server 运行时 →