一个 Agent 的标准解剖
第 1 周收官。前 4 天的概念(图 / State / 节点 / supervisor),今天全部落到"一个真实 Agent 的目录结构 + 真源码"上。以旗舰 sre-rca 为范本:黑板怎么定义、图怎么装配、专家工厂怎么把 30 行代码背后的一整套模板收进 toolkit、失败怎么被双层兜住。这是 Day 14 逐行精读的地图,也是第 2 周钻进 toolkit 之前的最后一块拼图。
标准目录结构:每个 Agent 长一个样
本框架的一大好处是所有 Agent 目录结构统一——看懂一个就看懂全部。以 apps/sre-rca-agent/sre_rca/ 为例(下面每个文件今天都会读到真代码):
回忆 Day 01 的铁律:L3 业务 Agent 只写 State / Specialists / Prompts / DAG(图) 这四类东西,护栏能力全从 toolkit import。上面这个结构正是这条铁律的体现。真实文件清单印证了这点——nodes/ 下是 triage/recall/rag/synthesizer/critic/writeback,specialists/ 下是 trace_sp/metric_sp/deploy_sp/log_sp + 一个 _factory.py(后面 L08 会揭穿这个"化石")。
state.py — 共享黑板与两种 Reducer
state.py:15 定义 class RcaState(TypedDict, total=False)。回忆 Day 03:这是整张图的"黑板",每个节点读它、返回部分字典合并回去。真代码按"数据流阶段"分了组:
class RcaState(TypedDict, total=False): # state.py:15
# ── 输入 ──
trace_id: str
service_name: str | None
user_question: str
# ── Triage 输出 ──
alert_type: str
need_full_dag: bool
# ── 4 Specialist 输出(字段独立,并行写零冲突)── state.py:29
trace_finding: dict[str, Any] | None
metric_finding: dict[str, Any] | None
deploy_finding: dict[str, Any] | None
log_finding: dict[str, Any] | None
# ── Synthesizer / Critic 输出 ──
conclusion: dict[str, Any] | None
critique_passed: bool
critique_layer: int # 1=代码层失败 / 2=LLM层失败 / 0=通过
# ── Reducer:累加(防并发竞态)── state.py:48
retry_count: Annotated[int, add]
Annotated[int, add] 的字段。Critic 打回重做时可能有并发写入,用累加 Reducer 而非覆盖,避免两次 +1 互相盖掉导致计数丢失。它是判断"重试是否触顶"的依据。0=质检通过、1=被代码层拦下、2=被 LLM 层拦下。一个 int 就把"过没过、被哪层拦的"都说清了(Day 07 主角)。findings: dict?如果 4 个专家都往同一个 findings 字段写,并行时就会互相覆盖——LangGraph 的默认 Reducer 是"覆盖",最后一个写的赢。拆成 4 个独立字段,每个专家只碰自己那格,并行零冲突、无需给字段配特殊 Reducer。这是"用数据结构的设计规避并发问题",比加锁优雅得多。retry_count 是"可能被并发 +1"的计数器,才值得用 add Reducer。这体现了一个原则:Reducer 是成本,能不用就不用,只在真有并发累加需求处才上。state.py 顶部注释把这个决策写得清清楚楚。builder.py — 图的装配(最重要)
核心函数 build_rca_graph()(builder.py:42)。它就是 Day 03/04 学的那套 StateGraph → add_node → add_edge → compile,只是节点更多。整张图长这样:
关键代码(来自真实 builder.py,行号已核对):
builder = StateGraph(RcaState) # builder.py:73
builder.add_node("triage", triage_node) # builder.py:76
builder.add_node("recall", build_recall_node(episodic))
builder.add_node("trace_sp", trace_sp_node) # 4 个专家节点
builder.add_node("metric_sp", metric_sp_node)
builder.add_node("deploy_sp", deploy_sp_node)
builder.add_node("log_sp", log_sp_node)
builder.add_node("critic", build_critic_for_rca())
# ── 线性入口 ── builder.py:88
builder.add_edge(START, "triage")
builder.add_edge("triage", "recall")
# ── 4 SP 并行扇出 + 扇入到 RAG ── builder.py:92
for sp in ("trace_sp", "metric_sp", "deploy_sp", "log_sp"):
builder.add_edge("recall", sp) # recall → 每个专家
builder.add_edge(sp, "rag") # 每个专家 → rag
builder.add_edge("rag", "synthesizer")
builder.add_edge("synthesizer", "critic")
# ── Critic 三态条件路由:直接用 toolkit 封好的!── builder.py:100
critic_router = build_critic_router(
pass_dest="writeback", retry_dest="synthesizer",
end_dest=END, max_retries=max_critic_retries)
builder.add_conditional_edges("critic", critic_router,
{"writeback": "writeback", "synthesizer": "synthesizer", END: END})
builder.add_edge("writeback", END)
return builder.compile(checkpointer=checkpointer) # builder.py:118
逐段翻译:(1)StateGraph(RcaState) 建一张以刚才那块黑板为状态的图;(2)10 个节点一个个 add_node;(3)并行的秘密就在那个 for 循环——4 个专家都"从 recall 出发、都汇入 rag",LangGraph 看到这种"同源扇出、同汇扇入"就自动让它们并行,业务作者一个线程都不用管;(4)Critic 后面接的不是手写 if/else,而是从 toolkit import 的 build_critic_router——通过/打回重做/触顶三态路由全封好了(Day 07/08 细讲);(5)compile(checkpointer=...) 收尾,checkpointer 是"工作记忆"(Day 09)。
{"alert":"支付超时率突增到8%"} 进门 →(triage)门口分诊师傅用关键词把你归到 latency 类,没花一分钱 →(recall)档案员翻出去年相似旧 case 塞进你口袋 →(4 个专家并行)trace/metric/deploy/log 四位师傅同时给你取证,各自往公告板各自那格写 finding →(rag)查出相关 SOP →(synthesizer)请贵师傅 Sonnet 把所有材料综合成结论 →(critic)验收师傅 Haiku 挑刺:编造证据?证据不够?不过就把你打回 synthesizer 重来(最多 2 次,靠 retry_count 计数)→ 过了就 writeback 存档 → 出门。你全程只是"数据",被这张图推着走完了每道工序。nodes/ — 6 个普通节点
每个节点是 async def node(state) -> dict,或返回这种函数的"工厂"(把依赖闭包进去,比如 build_recall_node(episodic))。逐个看角色:
triage.py:17 的 _classify 用关键词字典把问题归到 5 类,need_full_dag = alert_type != "HEALTH_QUERY"。体现"能用代码就别用 LLM"的省钱哲学build_critic_for_rca()。Day 07 主角should_writeback 守门(只有质检通过且信息充分才写),把这次 case 存回记忆库get_llm(tier) 怎么支撑这种分档。specialists/ — 先看数据结构 SpecialistConfig
4 个专家文件每个都只有约 30 行,因为它们都是"填一份配置 SpecialistConfig → 交给工厂 make_specialist_node"。先把这份配置的字段看清(specialists/factory.py:37):
@dataclass
class SpecialistConfig: # factory.py:37
state_field: str # 结果写进 state 的哪个字段(如 'metric_finding')
system_prompt: str # system prompt 内容(业务从 prompts/*.md 读)
fetch_fn: Callable[[dict], Awaitable[dict]] # 拉证据的 async 函数
fallback_finding: dict = field(default_factory=lambda: { # 抓取/执行失败时的降级结果
"health": "unknown", "confidence": "低",
"evidence_ids": [], "hint": "证据获取失败"})
model_tier: ModelTier = "sonnet"
name: str = "specialist"
cache_system: bool = False # 打开 prompt 缓存(Day 07/11)
# ─── 3 个可选自定义点(不填就用默认实现)───
user_prompt_builder: UserPromptBuilder | None = None
response_parser: ResponseParser | None = None
finding_post_process: FindingPostProcessor | None = None
{ok, data, error} 形状(Day 11 的 safe_tool_result 约定)。= None,即可选钩子。不填就走工厂的默认实现;业务有特殊需求才覆盖。trace_id/service_name/user_question 三件套、risk-reviewer 的 pr_id/diff_summary 就是靠覆盖 user_prompt_builder 实现的。make_specialist_node — 工厂内部的四步走读
配置填好后交给工厂 make_specialist_node(cfg)(factory.py:84)。它返回一个 async def specialist(state) -> dict 节点,内部就是一条"取数→喂 LLM→解析→写字段"的流水线,最后再包一层兜底。逐块读它的核心 inner_node(factory.py:93):
async def inner_node(state: dict) -> dict:
# ① 拉数据 · factory.py:95
tool_result = await cfg.fetch_fn(state)
if not tool_result.get("ok"): # 拉失败 → 早返回 fallback
finding = dict(cfg.fallback_finding)
finding["error"] = tool_result.get("error", "tool failed")
return {cfg.state_field: finding}
raw = tool_result["data"]
# ② 调 LLM · factory.py:104
llm = get_llm(cfg.model_tier, cache_system=cfg.cache_system)
response = await llm.ainvoke([
{"role": "system", "content": cfg.system_prompt},
{"role": "user", "content": user_prompt_builder(state, raw)}])
content = getattr(response, "content", str(response))
# ③ 解析 + 后处理 · factory.py:114
finding = response_parser(content)
finding.setdefault("evidence_ids", []) # 保证关键字段一定存在
if cfg.finding_post_process:
finding = cfg.finding_post_process(finding, raw)
return {cfg.state_field: finding}
逐步翻译:① 先调 fetch_fn 拉证据;拉失败(ok=False)就地返回 fallback,压根不浪费钱去调 LLM(这是第一处边界处理)。② 用配置里的档位 get_llm(cfg.model_tier) 调 LLM,把 system prompt + 用户内容喂进去。③ 解析 LLM 输出成 finding,setdefault("evidence_ids", []) 保证这个关键字段一定存在(哪怕 LLM 没给),然后如果配了后处理钩子就再加工一下,最后写回 state_field 那一格。
再看第 ④ 步——工厂返回前把 inner_node 包进兜底闸门(factory.py:125):
return wrap_with_fallback( # factory.py:125
inner_node,
FallbackConfig(
state_field=cfg.state_field,
fallback_finding=dict(cfg.fallback_finding, fallback=True)))
③ 的 response_parser 默认实现(factory.py:157)是三级容错:先 json.loads、再剥 markdown 代码块、都失败就返回 {"raw_text": content[:500], "_parse_warning": "LLM 输出非 JSON"}——返回一个带警告的 finding,而不是抛异常让图崩。跟 Day 04 dispatch 的解析套路一模一样。wrap_with_fallback — 双层兜底的第一道闸门
上一讲第 ④ 步那个 wrap_with_fallback 是 toolkit failsafe 的第一道闸门(Day 08 主角之一),代码很短(node_fallback.py:33):
def wrap_with_fallback(node_fn, config: FallbackConfig): # node_fallback.py:33
async def wrapped(state: dict) -> dict:
try:
return await node_fn(state) # 正常就原样返回
except Exception as e: # node_fallback.py:55
logger.log(config.log_level, "Node failed for state_field=%s: %s",
config.state_field, e, exc_info=True)
return {config.state_field: dict(config.fallback_finding, error=str(e))}
wrapped.__name__ = getattr(node_fn, "__name__", "wrapped_node")
wrapped.__wrapped__ = node_fn
return wrapped
大白话:把任意 async 节点函数包成"永远不抛异常"的版本。正常时原样返回;一旦内部抛任何异常,它 catch 住、打日志、返回一个带 error 的 fallback_finding。这样这个专家节点再怎么炸,也只是往黑板写一格"降级结果",绝不会让整张图崩掉。
ok=False(比如接口 404);而 wrap_with_fallback 兜的是"预期外的失败"——LLM 调用抛异常、解析器崩了、后处理钩子里有 bug、甚至你没想到的空指针。内层处理已知问题、外层兜住未知问题,这就是"双层兜底"。注意外层的 fallback 会额外标 fallback=True(factory.py:129),下游一看就知道"这是兜底出来的,别太当真"。get_metrics 接口超时 → 返回 {ok:False, error:"timeout"} → ① 就地返回 {"metric_finding":{"health":"unknown","confidence":"低","error":"timeout"}},没调 LLM,省钱。场景 B(预期外):LLM 返回一个诡异结构,后处理钩子里
KeyError 崩了 → 外层 wrap_with_fallback catch 住 → 返回 {"metric_finding":{"health":"unknown", "fallback":True, "error":"KeyError: ..."}},图继续跑。业务作者一行 try/except 都不用写。
metric_sp.py — 一个真实专家有多"薄" + DEPRECATED 化石
前面读了配置和工厂,现在看真实的一个专家文件——specialists/metric_sp.py,去掉 import 和读 prompt,实质就一个配置(这是完整文件,没有省略):
from ai_trust_toolkit.specialists import SpecialistConfig, make_specialist_node
from ..tools import get_metrics
_PROMPT = (Path(__file__).resolve().parents[2] / "prompts"
/ "metric-specialist-v1.md").read_text(encoding="utf-8")
metric_sp_node = make_specialist_node( # metric_sp.py:13
SpecialistConfig(
name="metric_sp",
state_field="metric_finding", # 写黑板哪一格
system_prompt=_PROMPT, # 从 prompts/ 读作业指导书
fetch_fn=lambda state: get_metrics( # 怎么拉证据
service=state.get("service_name") or "unknown", window_sec=60),
fallback_finding={"health": "unknown", "confidence": "低",
"evidence_ids": [], "hint": "指标获取失败"},
model_tier="sonnet",
))
就这些。没有 try/except、没有 LLM 调用循环、没有 JSON 解析、没有并发处理——因为这些全在 L06/L07 那个工厂里。业务作者只回答 5 个问题:叫什么名、写哪格、用哪份 prompt、怎么拉数据、失败给什么降级值。这就是"专家为什么只有 30 行"的真相。其它三个专家(trace/deploy/log)结构一模一样,只是换 state_field / fetch_fn / prompt / tier。
service_name="pay-svc" → fetch_fn 调 get_metrics(service="pay-svc", window_sec=60) 抓 60 秒指标(本地是 mock)→ 拼进 prompt 喂 Sonnet → 解析 → 回写 {"metric_finding":{"health":"degraded", "confidence":"中", "evidence_ids":["ev-M2"]}} 到黑板。若抓数据报错 → 自动降级返回 {"health":"unknown","confidence":"低","hint":"指标获取失败"},图不崩。
目录里的"活化石":specialists/_factory.py
你在 specialists/ 里会看到一个 _factory.py,打开一看头顶写着大大的 DEPRECATED(_factory.py:1):
"""DEPRECATED —— 已上移到 ai_trust_toolkit.specialists(toolkit v0.2.0)。
本文件保留仅为兼容旧 import 路径,未来 v0.3 删除。新代码请用:
from ai_trust_toolkit.specialists import SpecialistConfig, make_specialist_node
"""
from ai_trust_toolkit.specialists import ( # noqa: F401 re-export
SpecialistConfig, default_response_parser,
default_user_prompt_builder, make_specialist_node)
_factory.py)。等第二个 agent(risk-reviewer)也需要几乎一样的工厂时,它才被"上移"进 toolkit,旧文件改成 17 行的 re-export 空壳保持向后兼容。这就是 Day 06 要讲的"渐进抽象"——第二次重复出现才抽,且不删旧路径而是留空壳渐进迁移。看到 from ._factory import ... 和 from ai_trust_toolkit.specialists import ... 指向同一批符号,你就见证了这次演进。tools / prompts / server·main / BOM
tools/ — 只读数据源
tools/observability.py 定义 get_trace_by_id / get_metrics / get_deployment_history / search_logs 四个工具。两个要点:每个都用 @safe_tool_result(fallback=...) 装饰——工具永不抛异常,失败返回约定的 {ok,data,error} 兜底(正好衔接 L06 工厂 ① 步读的那个 ok 字段,Day 11 细讲);还有 USE_REAL 开关:默认走 mock,生产接 SkyWalking/Prometheus。
prompts/*.md — 版本化的工程文档
真实目录里有 7 个 *-v1.md:triage-v1 / trace/metric/deploy/log-specialist-v1 / synthesizer-v1 / critic-v1。它们不是干巴巴的 prompt,而是带教学说明的工程文档(正文 + 用户模板 + 设计说明 + Token 估算 + 变更记录)。
-v1 版本号是为了 A/B 测试和回滚——想试新 prompt 就加 -v2,评测对比后再切换,老版本随时能退回。这也是把 prompt 当"代码"严肃管理的体现。server.py / main.py — 两个入口
server.py · build_router()
产出一个 APIRouter,能被平台 gov-agents-server 多 agent 同进程挂载。核心是(server.py:66):graph = build_rca_graph() 后,业务只写 _invoke / _stream,统一的鉴权/envelope/落库/指标全由 build_v1_router 自动装(Day 12)。
main.py · 独立 FastAPI
单独部署这个 agent 时的入口:一个 FastAPI + include_router + 一行接齐可观测性。
还有个宝藏端点 /agents/rca/graph(server.py:142):把每个节点的 role/model/purpose 以 JSON 返回——相当于这个 Agent 的"自我说明书",Day 14 会拿它当权威参照。
pyproject.toml — 依赖走 BOM,一行锁死
dependencies = [
"ai-trust-toolkit-bom[core,supervisor,monitor]==0.6.0",
]
[core,supervisor,monitor] 是"extras",按需勾选能力组。CI 里有专门闸门(Day 18)禁止 agent 绕过 BOM 直接 pin 子模块版本。今日小结 + 动手
🧠 第 1 周收官自测
- 一个 Agent 的标准目录有哪些文件,各管什么?(state/builder/nodes/specialists/tools/prompts/server/main)
- 读一个陌生 agent 先看哪个文件?(
builder.py的 StateGraph→add_edge→compile 段) - RcaState 里为什么 4 个 finding 独立、只有 retry_count 用累加 Reducer?(并行零冲突 vs 并发累加防覆盖)
- 专家为什么每个才 30 行?(填 SpecialistConfig 4 个强制项,工厂封了取数/LLM/解析/写字段/双层兜底)
- make_specialist_node 里的"双层兜底"是哪两层?(inner_node 内 ok=False 就地降级 + 外层 wrap_with_fallback 兜住任意异常)
- _factory.py 的 DEPRECATED 说明了什么?(渐进抽象:第二次重复才上移,留空壳兼容)
✋ 动手:把结构落到真实文件
# 1. 看旗舰 agent 的完整结构
find apps/sre-rca-agent/sre_rca -type f -name "*.py" | sort
ls apps/sre-rca-agent/prompts/ # 数一下真的是 7 个 *-v1.md
# 2. 精读黑板的两种 Reducer
sed -n '15,60p' apps/sre-rca-agent/sre_rca/state.py
# 3. 精读图的装配(今天的核心)
sed -n '73,118p' apps/sre-rca-agent/sre_rca/builder.py
# 4. 读专家工厂的数据结构 + 四步流水线 + 双层兜底
sed -n '37,131p' packages/ai-trust-toolkit/src/ai_trust_toolkit/specialists/factory.py
sed -n '33,67p' packages/ai-trust-toolkit/src/ai_trust_toolkit/failsafe/node_fallback.py
# 5. 看一个专家有多"薄" + 那块 DEPRECATED 化石
cat apps/sre-rca-agent/sre_rca/specialists/metric_sp.py
cat apps/sre-rca-agent/sre_rca/specialists/_factory.py