从零开发一个新 Agent + L4 Skills
学了 18 天,今天把知识用起来——亲手 scaffold 一个新 agent,跑通全流程。今天我们不停在"命令示意",而是把 scaffold 命令、6 pattern 推导表、注册表懒加载、L4 skills 挂载全部翻到真源码逐行读。你会看到平台的一条核心信念:新住户"薄到只有 30 行",全靠底座工厂 + 显式注册表把脏活兜住。盖完这户,Day 20 就把 20 天串成一张完整地图收官。
两种起 agent 的方式
框架提供两条路径,先搞清你该走哪条:
① 平台 monorepo 内加
agent 直接进 apps/,成为 uv workspace 成员。用 agentctl new agent 脚手架 + agent-onboarding skill 指路。平台核心团队、大多数情况走这条。
② 独立 git 仓开发
在自己的仓里,通过公司 Nexus 一行 BOM 依赖用 toolkit。用 standalone-repo-agent skill + examples/standalone-agent-template 模板。没有 monorepo 权限时走这条(见番外篇 Day 21)。
今天主要讲①(monorepo 内),②见 番外篇 9 步。回忆 Day 05:无论哪条,agent 的目录结构完全一致(state/builder/nodes/specialists/prompts/server),所以学会一种就都会。
AGENT_REGISTRY,现在已经挂了 18 个 agent(sre-rca / risk-reviewer / doc-checker / biz-link-gov / agent-creator / spec-author…)。它们全是照今天这套路子加进来的——你今天学的不是玩具流程,是平台真实的扩张方式。决策树 + scaffold 命令(读真实签名)
agent-onboarding skill 会带你走一棵决策树。核心几问(呼应 Day 01 的分层铁律):
是 → 应该放进 toolkit(L2),不是新 agent。否 → 继续。
是 → 放 apps/(L3),起新 agent。
是 → 走 OpenSpec cookbook(Day 20)。否 → 走快速 cookbook(docs/new-agent-cookbook.md)。
决定了"起新 agent",就用 CLI 脚手架 agentctl new agent。它的真实签名在 packages/gov-agents-cli/src/gov_agents_cli/commands/new_agent.py:51,参数比你想的多——直接读源码里的 Typer option(裁剪自 new_agent()):
def new_agent( # new_agent.py:51
name: Annotated[str, typer.Argument(help="agent 名 · kebab-case · 如 release-notes-bot")],
pattern: Annotated[str, typer.Option("--pattern",
help="6 选 1 · supervisor / rag / monitor / doc / business-data / minimal")] = "supervisor",
starters: Annotated[str | None, typer.Option("--starters",
help="comma-sep · 子集 of {core, supervisor, rag, monitor, doc, business-data}")] = None,
specialists: Annotated[str, typer.Option("--specialists",
help="comma-sep snake_case · supervisor/monitor pattern 用")] = "",
critic: Annotated[str, typer.Option("--critic", help="4 选 1 · l1 / l2 / both / off")] = "both",
create_change: Annotated[str | None, typer.Option("--create-change",
help="同时起 OpenSpec change · 传 change-name 如 add-release-notes-bot")] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", help="只 echo 不写文件")] = False,
) -> None:
逐个翻译这些开关,它们全是前几天学过的东西:--pattern 选你的 agent 长啥样(6 选 1,L03 拆);--starters 就是 Day 05 讲的 BOM extras(要哪几组能力);--specialists 是 Day 05 的专家名字(会帮你生成对应 specialists/*.py);--critic both 默认给你装上 Day 07 的双层 Critic;--create-change 顺手起一个 Day 20 的 OpenSpec 提案;--dry-run 先干跑看要生成啥、不落盘。
--pattern 默认值是 "supervisor" 而不是 "minimal"?
你可能觉得"最小骨架"当默认更安全。但平台的真实经验是:90% 的业务 agent 最后都长成"多专家并行 + Synth + Critic"那个样子(就是 sre-rca 那张图)。把最常用的形态设成默认,多数人一条命令就到位;真要极简的人才显式写 --pattern minimal。默认值应该服务大多数,而不是服务"理论上最安全"。--critic 默认 both(L1+L2 全开)?
Critic 是要花钱的(L2 调 Haiku)。但平台宁可默认全开、让你显式 --critic off 关掉,也不默认关。因为"防幻觉"是"可信"的底线,把它设成 opt-out(默认开、要关得主动)而非 opt-in——防止有人图省事忘了加,产出一个会胡说的 agent。这跟"评测样本 + Critic 是准入门槛"(Day 10)是同一条纪律:可信靠机制默认强制,不靠自觉。6 个 pattern:翻开推导表源码
上面 --pattern 6 选 1 到底是什么?别停在文档,直接读推导规则的真源码 packages/gov-agents-cli/src/gov_agents_cli/_patterns.py:9:
# _patterns.py:9 —— 6 个 pattern · 对应 6 starter(minimal 不带 supervisor)
PATTERNS: frozenset[str] = frozenset(
{"supervisor", "rag", "monitor", "doc", "business-data", "minimal"})
# _patterns.py:22 —— pattern → 默认 starter 推导表(用户没传 --starters 走这里)
PATTERN_DEFAULTS: dict[str, list[str]] = {
"supervisor": ["core", "supervisor"],
"rag": ["core", "supervisor", "rag"],
"monitor": ["core", "supervisor", "monitor"],
"doc": ["core", "supervisor", "doc"],
"business-data": ["core", "supervisor", "business-data"],
"minimal": ["core"],
}
逐行读:① PATTERNS 用 frozenset 锁死这 6 个合法值——不可变,天然当"白名单"用,传别的名字直接报错。② PATTERN_DEFAULTS 是核心:每个 pattern 映射到一组 BOM extras(starter 包)。看规律——除了 minimal,其它 5 个都自带 ["core", "supervisor"],再叠各自领域包(rag 加检索、monitor 加监控、doc 加文档、business-data 加业务库)。这正好印证了 Day 05 那句"薄业务 + 厚 toolkit":pattern 的本质就是"帮你勾好该装哪几组 toolkit 能力"。
紧接着的 resolve_starters()(_patterns.py:34)有一处很有意思的强制:
def resolve_starters(pattern: str, user_starters: str | None) -> list[str]: # _patterns.py:34
if user_starters:
result = [s.strip() for s in user_starters.split(",") if s.strip()]
unknown = [s for s in result if s not in KNOWN_STARTERS]
if unknown:
raise ValueError(f"unknown starters: {unknown} · known: {sorted(KNOWN_STARTERS)}")
if "core" not in result: # ← core 必带 · 自动补齐
result.insert(0, "core")
return result
...
return list(PATTERN_DEFAULTS[pattern])
KNOWN_STARTERS 白名单里,立刻 raise ValueError 并列出合法值——早失败、给明确提示,绝不生成一个"装了不存在的包"的坏骨架。第二条:无论你怎么写,core 一定被补进去(result.insert(0, "core"))。因为 core 是可信底座的地基(critic/failsafe/llm/api 全在里面),漏了它 agent 根本跑不起来——所以框架不信任用户一定记得写,替你兜住。_patterns.py:50 的兜底:if pattern not in PATTERN_DEFAULTS: raise ValueError(f"invalid pattern: {pattern!r} · choose one of: {sorted(PATTERNS)}")。它不会默默给你一个空骨架,而是把 6 个合法值全列出来让你改。这是"fail-fast + 可操作报错"的典型——错误信息本身就是修复指南。复制模板 + 命名铁律(读 cookbook 真 diff)
快速通道其实不一定用 scaffold——cookbook docs/new-agent-cookbook.md §1 推荐最朴素的办法:挑一个业务模式最像你的现有 agent,cp -r 一份来改。真命令(裁自 cookbook 第 53-68 行):
# cookbook §1 · 复制模板(5 min)
cp -r apps/sre-rca-agent apps/my-agent
cd apps/my-agent
# 顶层 Python 包名必须独特(snake_case · 不能跟其它 app 撞)
mv sre_rca my_agent
pip install -e 同名包互相覆盖,只有一个能 import。" ✅ 正例 sre_rca / biz_link_gov / my_agent;❌ 反例 app / agent / src。为什么这么严? 因为所有 app 装在同一个 uv workspace 里(可编辑安装),两个包都叫 agent 时,Python 的 import 只会命中一个——另一个 agent 神秘地"消失",且报错极难懂。这是 Day 02 讲的"可编辑安装"机制留下的坑,用命名纪律绕过。复制完,要让平台"认"这户,cookbook §1 列了几处真实 diff。先是根 workspace 登记(这是 Day 02 的 uv workspace):
# pyproject.toml (repo root) —— 加你的 agent
[tool.uv.workspace]
members = [ ...,
+ "apps/my-agent" ]
[tool.pytest.ini_options]
testpaths = [ ...,
+ "apps/my-agent/tests" ] # ← 让 CI 跑你的测试(Day 18)
再是向 supervisor 注册(下一讲 L06 会把这张表翻个底朝天):
# packages/gov-agents-server/src/gov_agents_server/main.py
AGENT_REGISTRY: dict[str, tuple[str, str]] = { ...,
+ "my-agent": ("my_agent.server", "build_router") }
server.py:整个 agent 的入口只有 30 行
build_v1_router。看 cookbook §7 给的最小可跑范例 echo-agent(docs/new-agent-cookbook.md:420 起,这是完整的 30 行 server.py,没省略):
"""echo-agent · 最小 v1 agent 示例 · 把 body.msg 回显。"""
from __future__ import annotations
from typing import Any
from ai_trust_toolkit.api import (
AgentOutcome, CallContext, CaseStore, CostTracker,
build_v1_router, make_case_store_from_env)
from fastapi import APIRouter
async def _invoke(body: dict[str, Any], ctx: CallContext, tracker: CostTracker) -> AgentOutcome:
msg = body.get("msg", "")
return AgentOutcome(
status="success",
result={"echo": msg, "caller": ctx.user_id, "msg_len": len(msg)})
def build_router(case_store: CaseStore | None = None) -> APIRouter:
router = APIRouter(tags=["echo"])
router.include_router(
build_v1_router(
agent_name="echo",
invoke_fn=_invoke,
case_store=case_store or make_case_store_from_env()))
return router
逐块读:① 你只写一个 _invoke——签名固定收三样:body(请求体)、ctx(调用上下文,里面有 user_id / anthropic_api_key)、tracker(成本记账器)。② 干完活返回一个 AgentOutcome(status + result)。③ build_router 里把 _invoke 交给工厂 build_v1_router(agent_name=..., invoke_fn=_invoke, ...),收工。真实业务 agent 只是把 _invoke 内部换成"校验 body → await graph.ainvoke(...) → 翻译成 result",其余一字不变。
cookbook 紧接着列了工厂自动替你干的活(第 240 行"工厂自动负责"),照抄一段:
✅ 鉴权(X-User-Id / PLATFORM_AUTH_MODE 3 档)
✅ envelope 构造(case_id ULID / metrics from tracker.finalize / trace_id)
✅ case 持久化(成功 + critic_end + agent 内部 failed 都写 · 422/401 不写)
✅ Prometheus 指标(agent_invoke_cost_usd_total / _tokens_total / _duration_seconds)
✅ 老路径 /agents/my-agent/run + Deprecation: true header
✅ 跨 agent / 跨 user 404(防 case_id 枚举泄漏)
_invoke 只让你返 AgentOutcome,异常直接往外抛,而不是自己 try/except 拼 500?
cookbook 反模式表(第 525 行)明确点名:"自己拼 response BaseModel + raise HTTPException(500)" 是反模式,正确做法是 return AgentOutcome(status="failed", ...) 或直接让异常冒泡给工厂。为什么? 因为错误的 envelope 形状、状态码、落不落库这些"横切策略"必须全平台一致——只能有一个地方(工厂)说了算。每个 agent 自己拼,迟早拼得五花八门,监控和 case 查询就乱了。把"怎么响应错误"从业务手里收走,是保证平台一致性的代价,也是它的价值。curl -X POST /v1/agent/echo/invoke -H "X-User-Id: dev" -d '{"msg":"hi"}' → 工厂返回:{"case_id":"01HXY...", "agent":"echo", "status":"success", "result":{"echo":"hi","caller":"dev","msg_len":2}, "metrics":{...}, "trace_id":"01HZZ..."}注意
case_id / trace_id / metrics 你一个字没写——全是工厂加的。你只贡献了 result 里那三个字段。注册表走读:一个 dict 就是全楼住户名册
Day 12 讲过"发现机制是显式注册表,不是自动扫描"。今天把这张表翻开看真身。它就是 gov_agents_server/main.py:62 一个普通 dict(裁剪,真有 18 条):
# main.py:62 —— Agent 注册表(名字 → (模块路径, 工厂函数名))
AGENT_REGISTRY: dict[str, tuple[str, str]] = {
"sre-rca": ("sre_rca.server", "build_router"),
"risk-reviewer": ("risk_reviewer.server", "build_router"),
"doc-checker": ("doc_checker.server", "build_router"),
"biz-link-gov": ("biz_link_gov.server", "build_router"),
"agent-creator": ("agent_creator.server", "build_router"),
"spec-author": ("spec_author.server", "build_router"),
# ... 共 18 条 ...
"my-agent": ("my_agent.server", "build_router"), # ← 你加的这行
}
/v1/agent/my-agent/invoke。也是 ENABLED_AGENTS 环境变量里写的名字。.server)。平台稍后 importlib.import_module 它。build_router。约定俗成都叫这名。光有名册还不够,平台怎么用它才是关键。先看启动时怎么校验你要挂哪些(main.py:289):
def _parse_enabled(raw: str | None) -> list[str]: # main.py:289
if not raw or not raw.strip():
return list(AGENT_REGISTRY) # 没设 → 默认全挂
names = [a.strip() for a in raw.split(",") if a.strip()]
unknown = [n for n in names if n not in AGENT_REGISTRY]
if unknown:
raise RuntimeError(f"未知 agent: {unknown!r}。合法值: {sorted(AGENT_REGISTRY)}")
return names
逐行:读环境变量 ENABLED_AGENTS(逗号分隔);空 → 默认挂全部;只要你写的名字有一个不在注册表里,立刻 raise RuntimeError 并列出全部合法值——平台启动失败而不是带病运行。这就是"显式注册表"作为一道门槛的价值。
校验过了,真正把 agent 挂载靠 _load_router(main.py:299,裁剪核心)——这是一段漂亮的懒加载 + 依赖注入:
def _load_router(name, *, case_store=None, pool=None, app=None) -> APIRouter: # main.py:299
mod_path, fn_name = AGENT_REGISTRY[name] # ① 查名册拿(模块,函数名)
mod = importlib.import_module(mod_path) # ② 真正 import 你的 server.py
fn = getattr(mod, fn_name) # ③ 取出 build_router
if name in AGENTS_NEED_POOL: # ④ 要数据库的 agent 注入 pool
return fn(pool)
kwargs = {"case_store": case_store} # ⑤ 普通 agent 注入共享 case_store
sig = _inspect.signature(fn) # ⑥ 探测 build_router 要不要 app 参数
if "app" in sig.parameters and app is not None:
kwargs["app"] = app
return fn(**kwargs)
importlib.import_module 动态加载,再 getattr 拿到 build_router。这叫懒加载:ENABLED_AGENTS 没选你,你的代码根本不会被 import,某个 agent 有 import 错误也不会拖垮别人。④⑤ 是依赖注入:需要数据库的 agent(AGENTS_NEED_POOL)给它 pool,普通 agent 给共享的 case_store。⑥ 最妙——用 inspect.signature 探测你的 build_router 有没有 app 参数,有才注入。这样老 agent(不要 app)和新 agent(要 app 做 sandbox)用同一个加载器,谁都不破。👶 小白:注册表里明明有 18 个 agent,为什么我起服务只想跑我自己那个?
👨🏫 老师:用 ENABLED_AGENTS=my-agent uv run uvicorn gov_agents_server.main:app。_parse_enabled 会只挑出 my-agent 挂载,其它 17 个的 server.py 压根不会被 import(懒加载的好处)——本地开发既快又不会被别的 agent 的依赖问题干扰。上生产才把该挂的都列上。
准入门槛 + 反模式表(读 cookbook 原文)
回忆 Day 10 的硬规矩:新 Agent 必须先有评测样本 + Critic,才能合并。cookbook checklist(docs/new-agent-cookbook.md:539)把"接入完成前自检"逐条列了出来,照抄关键几条:
# cookbook §9 checklist(裁剪)
[ ] 顶层包名 snake_case · 跟其它 agent 不撞
[ ] server.py 没出现 @router.post("/agents/...") · 全走 build_v1_router
[ ] _invoke 抛 HTTPException(422/404) 透传 · 其它异常让 router 自动转 status=failed
[ ] 测试覆盖:v1 invoke 200 + 老路径 alias + 422 +(可选)stream
[ ] supervisor AGENT_REGISTRY 加一条
[ ] root pyproject.toml workspace members + testpaths + mypy ignore 加一条
[ ] OpenSpec change 跟代码同 commit
cookbook 还给了一张反模式表(第 525 行)——"看到就该警觉"。这是全天最值钱的一张表,因为它把"没用框架的人会怎么写错"一条条列了出来:
| ❌ 反模式 | ✅ 正确做法 |
|---|---|
@router.post("/agents/my-agent/run") 手写路由 | build_v1_router(invoke_fn=_invoke) 工厂 |
自己写 SSE gen() + yield _sse(...) 字符串 | _stream(...) async generator + yield AgentOutcome |
prev=os.environ.get("ANTHROPIC_API_KEY"); ... finally: | with anthropic_key_override(ctx.anthropic_api_key): |
自己拼 response BaseModel + raise HTTPException(500) | return AgentOutcome(status="failed", result={...}) |
顶层包名用 agent / app / src | snake_case + agent-specific(my_agent) |
AGENTS.md 收尾金句"保持评测样本同库、保持 Critic 兜底"的落地。📎 没 monorepo 权限?走独立仓形态②——用 examples/standalone-agent-template 模板 + Nexus 一行 BOM 依赖,完整手把手 9 步(下模板 → Nexus 凭据 → 跑通 → 写业务图 → telemetry → CLI/MCP/pod → 测 → 安全自检 → 上线)见 番外篇 · 独立仓 Agent 开发 9 步 →
L4 Skills:翻开 registry.json + SKILL.md
最后补上 Day 01 架构图最顶层的 L4 —— skills/。它是给 IDE 里的 AI(Claude Code / Cursor)用的一句话 SOP:把"该怎么调这些 agent"的操作流程沉淀成可触发的技能。别停在概念,直接读一个真 SKILL.md 的头部 frontmatter(skills/release-gate/SKILL.md:1):
---
name: release-gate
description: 给 PR diff / commit 区间 · 跑 risk-reviewer agent · 出 risk_score(0-100)·
高于阈值阻断 + 列降险动作。触发词:上线闸 / release gate / 这个 PR 能合吗 / risk_score
tier: developer
tools:
- agentctl invoke risk-review # ← skill 向下只调 agentctl / MCP / git
- agentctl case
- git diff
requires:
agentctl_min_version: "0.1.0"
enabled_agents:
- risk-review # ← 声明它依赖哪个 agent 挂着
---
developer(开发者用)/ oncall(值班用)/ user-facing(业务方用)。决定给谁看。agentctl / git / MCP,不含任何业务代码 import。risk-review)。缺了这个 agent,skill 跑不了。这些 SKILL.md 被 agentctl skill index 汇总成一个 skills/registry.json(当前 12 个 skill:8 个 developer + 3 个 oncall + 1 个 user-facing)。平台首页会读它——看 gov_agents_server/main.py:347 的加载函数:
def _load_skill_registry(skills_dir: Path | None) -> list[dict]: # main.py:347
if skills_dir is None:
return []
f = skills_dir / "registry.json"
if not f.exists():
return []
try:
return json.loads(f.read_text(encoding="utf-8")).get("entries", []) or []
except (OSError, json.JSONDecodeError) as e:
log.warning("load skills registry failed: %s", e)
return [] # ← 读失败也只是首页少个板块 · 绝不拖垮平台
except ... return []。skills catalog 只是首页一个锦上添花的板块——registry.json 写坏了、文件没了,平台核心(那 18 个 agent 的 invoke)该照跑。所以这里吞掉异常、降级为"少显示一个板块",打个 warning 就算。这是 Day 08"优雅降级"哲学在一个不起眼角落的贯彻:非核心功能坏了,不许连累核心。今日小结 + 动手
🧠 今天你应该能回答(都能指到真源码)
agentctl new agent的--pattern默认为什么是supervisor、--critic为什么默认both?(默认服务大多数 + 可信默认强制)- 6 个 pattern 的本质是什么?(
_patterns.py里 pattern→BOM extras 的推导表,core 强制补齐) - 一个 agent 的 server.py 为什么只有 30 行?(
_invoke交给build_v1_router,鉴权/envelope/落库/指标全工厂给) AGENT_REGISTRY是什么、平台怎么用它挂 agent?(dict 名册 →_parse_enabled校验 →_load_router懒加载 + 依赖注入)- 为什么用显式注册表不用自动扫描?读 registry.json 失败为什么返空 list?(门槛 fail-fast + 非核心优雅降级)
- L4 Skill 的 SKILL.md 里 tier / requires.enabled_agents 各是什么?和 MCP 什么关系?
✋ 动手:把今天的源码全翻一遍
# 1. 读 scaffold 命令的真实签名 + 6 pattern 推导表
sed -n '51,100p' packages/gov-agents-cli/src/gov_agents_cli/commands/new_agent.py
sed -n '1,55p' packages/gov-agents-cli/src/gov_agents_cli/_patterns.py
# 2. 读最小 echo-agent(30 行 server.py 完整范例)+ 反模式表
sed -n '420,540p' docs/new-agent-cookbook.md
# 3. 读注册表 + 懒加载 + fail-fast 校验(今天核心)
sed -n '62,82p' packages/gov-agents-server/src/gov_agents_server/main.py # AGENT_REGISTRY
sed -n '289,332p' packages/gov-agents-server/src/gov_agents_server/main.py # _parse_enabled + _load_router
# 4. 只挂自己的 agent 跑起来(懒加载:其它 17 个不 import)
ENABLED_AGENTS=echo uv run uvicorn gov_agents_server.main:app --port 8080
# 5. 读一个真 SKILL.md + registry
cat skills/release-gate/SKILL.md ; sed -n '1,40p' skills/registry.json
agent/* 分支闸、release-coordinator 的并行 gate)+ 20 天全框架回顾 + 继续深入的路线图。