<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Qingyu Ge's Blog</title>
        <link>https://home.gqy20.top</link>
        <description>AI 编程 / RAG / 工程实践笔记</description>
        <lastBuildDate>Mon, 29 Jun 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-cn</language>
        <copyright>© 2026 Qingyu Ge</copyright>
        <item>
            <title><![CDATA[我用 Claude Agent SDK 搭了 14 个项目,沉淀出 9 个反复出现的模式]]></title>
            <link>https://home.gqy20.top/blog/260629_agent_sdk_patterns</link>
            <guid isPermaLink="false">260629_agent_sdk_patterns</guid>
            <pubDate>Mon, 29 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[从 biotools_agent、zotero_cli、cc-insights、manim-agent 到 TrumanWorld、werewolf、IssueLab,两年时间 14 个 Agent 项目反复重写同一个骨架。本文不讲用法教程,讲 9 个跨项目、跨语言、跨框架都会撞上的工程模式——其中 3 个(Schema 即 Prompt、事件总线、Trace/Span)是我最希望自己第一次做 Agent 时就看到的。]]></description>
            <content:encoded><![CDATA[<h1>我用 Claude Agent SDK 搭了 14 个项目,沉淀出 9 个反复出现的模式</h1>
<blockquote>
<p>从单一 SDK 调用,到能稳定运行的 Agent 系统,中间隔着 9 个我反复重写过的工程模式。这篇不是 API 文档,是一份跨项目、跨语言、跨框架的骨架清单。</p>
</blockquote>
<h2>1. 背景:为什么不是一篇"用法教程"</h2>
<p>打开 Claude Agent SDK 的官方文档,你会看到完整的方法签名、参数说明、几个 working example。然后你照着 example 写完第一个项目,提交,部署,跑通。<strong>然后你开始做第二个项目,发现 60% 的代码是从第一个项目复制过来的。</strong></p>
<p>这是我 2024 年到 2026 年真实的状态:</p>
<ul>
<li>第一个项目是 <code>biotools_agent</code>,照着 SDK 文档把 agent 跑起来,大概 3 天。</li>
<li>第二个项目是 <code>zotero_cli</code>,想给 AI agent 提供一个能直接调用的 CLI,又花了 3 天 —— 但其中 2 天是在<strong>重新发明 <code>biotools_agent</code> 里那个 LLM 抽象层</strong>。</li>
<li>第三个项目是 <code>cc-insights</code>,分析 Claude Code 历史数据,又花了 2 天把同样的事件系统写了一遍。</li>
<li>第四个是 <code>manim-agent</code>,端到端视频生成。这一次我没从零写,把 <code>cc-insights</code> 的 event store 复制过来,做了 schema 化输出。</li>
</ul>
<p>到了第 14 个项目 (<code>werewolf-agents</code>,多 agent 狼人杀),我已经不再思考"这个项目要用 SDK 的哪个 API",而是直接打开一个 starter 仓库,把 9 个骨架文件复制过去再改业务。<strong>单 SDK 的 API 学一周就够了,跨项目的不变骨架要两年才能看全。</strong></p>
<p>网上不缺 Claude Agent SDK 的"怎么用"教程,缺的是 <strong>"做过 N 个项目后哪些坑是通用的"</strong> 那份清单。这篇就是后者。</p>
<blockquote>
<p>📊 <strong>本文导览</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th>章节</th>
<th>主旨</th>
<th>阅读时长</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>1. 背景</strong></td>
<td>为什么写这篇、不是什么</td>
<td>3 分钟</td>
</tr>
<tr>
<td><strong>2. 为什么是 Claude Agent SDK</strong></td>
<td>框架定位 + 一句话选型依据</td>
<td>4 分钟</td>
</tr>
<tr>
<td><strong>3. 模式 1:别直接 import SDK</strong></td>
<td>LLM 抽象层是第一个分叉口</td>
<td>5 分钟</td>
</tr>
<tr>
<td><strong>4. 模式 2:JSON 是一等公民</strong></td>
<td>Agent 时代 CLI 的 I/O 契约</td>
<td>5 分钟</td>
</tr>
<tr>
<td><strong>5. 模式 3:Schema 即 Prompt</strong></td>
<td>Pydantic <code>Field(description=)</code> 双源合一</td>
<td>7 分钟</td>
</tr>
<tr>
<td><strong>6. 模式 4:工具设计三原则</strong></td>
<td>单一职责 / 幂等 / 可逆</td>
<td>5 分钟</td>
</tr>
<tr>
<td><strong>7. 模式 5:多 agent 编排</strong></td>
<td>fan-out 并行 vs pipeline 串行</td>
<td>6 分钟</td>
</tr>
<tr>
<td><strong>8. 模式 6:事件先于功能</strong></td>
<td>9-type EventType + JSONL EventStore</td>
<td>7 分钟</td>
</tr>
<tr>
<td><strong>9. 模式 7:Trace/Span</strong></td>
<td>OpenTelemetry 风格的可观测上下文</td>
<td>6 分钟</td>
</tr>
<tr>
<td><strong>10. 模式 8:配置外置</strong></td>
<td>dev / CI / Actions 三态共存</td>
<td>4 分钟</td>
</tr>
<tr>
<td><strong>11. 模式 9:从项目到飞轮</strong></td>
<td>agent 产出数据 → 改 agent</td>
<td>4 分钟</td>
</tr>
<tr>
<td><strong>12. 5 条军规</strong></td>
<td>收尾</td>
<td>2 分钟</td>
</tr>
<tr>
<td><strong>13. 起步</strong></td>
<td>复刻我的最小骨架</td>
<td>3 分钟</td>
</tr>
</tbody>
</table>
<hr>
<h2>2. 为什么是 Claude Agent SDK</h2>
<p>在 14 个项目里:</p>
<ul>
<li><code>bx-claim-demo</code> 用过 <strong>LangGraph</strong>(车险理赔的强流程编排)</li>
<li><code>gene-family-agent</code> 用过 <strong>Agno</strong>(多 agent 农学分析)</li>
<li><code>TrumanWorld</code> / <code>werewolf</code> / <code>mind</code> / <code>IssueLab</code> 是<strong>自研</strong>多 agent 编排(asyncio + 自定义 message bus)</li>
<li>其他 9 个是 <strong>Claude Agent SDK</strong></li>
</ul>
<p>主力放在 SDK,不是因为它最强,而是因为它<strong>最贴我 80% 的项目需求</strong>:</p>
<blockquote>
<p>💡 <strong>为什么以 SDK 为主力</strong></p>
<ol>
<li><strong>声明式优先</strong>:<code>AgentDef(description, prompt, tools, model)</code> 一个 dataclass 就把 agent 描述清楚,启动时直接 <code>to_dict()</code> 喂给 SDK —— 这跟 LangGraph 的「先画图」和 Agno 的「先装配 Team」形成对比</li>
<li><strong>工具生态完整</strong>:MCP 一等公民,Claude Code 同源,<code>@tool</code> 装饰器写一个函数就是工具,不用关心协议层</li>
<li><strong>Hooks 是真的挂上了生命周期</strong>:承接上一篇文章讲的范式迁移(详见 26028 钩子文 §4),SDK 的 <code>PreToolUse</code>/<code>PostToolUse</code> 是我看过的所有框架里<strong>对 hook 暴露最干净</strong>的</li>
<li><strong>流式消息能拿全</strong>:<code>StreamEvent</code> 把 thinking、tool_use、tool_result、text 全部结构化吐出来,这是后文 §6 事件总线的物质基础</li>
</ol>
</blockquote>
<p>当你有<strong>强流程、强状态机</strong>的需求(LangGraph 擅长),或者<strong>有现成多 agent 模板要复用</strong>(Agno 擅长),它们是更好的选择。<strong>但当你想做的是"让 AI 真的去做一件事",Claude Agent SDK 90% 的情况下是最短路径。</strong></p>
<p>本文后 9 个模式,<strong>有 6 个框架无关</strong>(在 LangGraph/Agno 里你也能用),有 3 个是 Claude Agent SDK 的特色(模式 3 的 <code>output_format</code>、模式 6 的 <code>StreamEvent</code>、模式 7 的 SDK 内建 trace ID)。我会标清楚。</p>
<hr>
<h2>3. 模式 1:别直接 import SDK —— LLM 抽象层是第一个分叉口</h2>
<p>我见过的所有"写到一半想重写"的 agent 项目,<strong>都栽在同一个坑</strong>:业务代码里散落着 <code>import anthropic</code> 或 <code>import openai</code> 或 <code>from claude_agent_sdk import query, ClaudeAgentOptions</code>,直接调用 SDK。</p>
<p>为什么不?</p>
<ul>
<li><strong>测试性</strong>:业务逻辑和 SDK 强耦合,unit test 必须 mock SDK,边际成本指数上升</li>
<li><strong>可替换性</strong>:某天想从 Claude 切到 Qwen、Ollama、本地模型,要全项目 grep 替换</li>
<li><strong>可观测性</strong>:SDK 的错误、retry、token 消耗你看不到,因为调用散在 17 个地方</li>
</ul>
<p>正确的做法:<strong>在 SDK 之上加一层 LLM 抽象</strong>。<code>biotools_agent</code> 里的 <code>AIAnalyzer</code>(<code>ai_analyzer_adapter.py</code>)就是例子:</p>
<pre><code class="language-python"># biotools_agent/src/ai_analyzer_adapter.py
class AIAnalyzer:
    """AI分析器统一接口,使用 Claude Code SDK 代理模式"""

    def __init__(self, config_override: dict = None):
        self.config_override = config_override or {}
        self.analyzer = AgentAIAnalyzer(self.config_override)

    def analyze_repository_content(self, repo_path: Path, repo_info, authors):
        # 任何业务方调这一个方法,不知道底下是 SDK 还是 HTTP
        return anyio.run(
            self.analyzer.analyze_repository_content, repo_path, repo_info, authors
        )
</code></pre>
<p>整个项目里<strong>只有 <code>AgentAIAnalyzer</code> 知道 SDK 的存在</strong>。其他 12 个模块只跟 <code>AIAnalyzer</code> 打交道 —— 测试时换成一个 stub,生产时切到 SDK,改 model 改到 1 行。</p>
<blockquote>
<p>⚠️ <strong>第一性原理</strong>:Agent 项目的可维护性,80% 取决于"换 LLM / 换 SDK 的成本"。这一层偷懒,后面所有模式(配置、事件、Trace)的成本都会翻倍。</p>
</blockquote>
<h3>3.1 抽象层的最小契约</h3>
<p>不管你用什么语言,LLM 抽象层至少要回答这五个问题:</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>抽象层应该提供</th>
</tr>
</thead>
<tbody>
<tr>
<td>我有一个 prompt,给我一个 completion</td>
<td><code>complete(prompt, **opts) -> str</code></td>
</tr>
<tr>
<td>我要流式拿到 token</td>
<td><code>stream(prompt) -> Iterator[Token]</code></td>
</tr>
<tr>
<td>输出要严格符合这个 schema</td>
<td><code>complete_with_schema(prompt, schema) -> Model</code></td>
</tr>
<tr>
<td>我想知道这次调用花了多少钱、几个 token</td>
<td><code>UsageReport</code> 返回对象</td>
</tr>
<tr>
<td>出错了/被 rate limit 了,我要能 retry</td>
<td>内部封装 retry,业务方不感知</td>
</tr>
</tbody>
</table>
<p>5 个问题,5 个方法。看着简单,但你能看到 <code>manim-agent</code> 的 <code>llm_client</code>、<code>zotero_cli</code> 的 backend 层,本质都是这五件事的具体实现。</p>
<h3>3.2 一个反例:SDK 装饰器在哪儿</h3>
<blockquote>
<p>🔥 <strong>常见错误</strong>:<code>@claude_tool</code> 装饰器是不是就把工具注册好了?还需要 LLM 抽象层干嘛?</p>
</blockquote>
<p>需要。装饰器解决的是"<strong>工具如何被 SDK 看到</strong>"的问题,LLM 抽象层解决的是"<strong>业务逻辑如何不依赖 SDK</strong>"的问题。两者不是替代关系。</p>
<p>我的项目里通常这样分层:</p>
<pre><code>业务模块 (analyze_repo, render_video, query_literature)
        ↓ 调
LLM 抽象层 (AIAnalyzer, CompletionService)    ← 业务只看到这一层
        ↓ 调
SDK 适配层 (ClaudeSDKAdapter, OpenAIAdapter)  ← 唯一允许 import SDK 的地方
        ↓ 调
Claude Agent SDK / OpenAI SDK / 本地模型
</code></pre>
<p><strong>5 个项目以后,我会用代码 review 卡死"业务模块里出现 import SDK 直接调用"。</strong></p>
<hr>
<h2>4. 模式 2:JSON 是一等公民 —— Agent 时代 CLI 的 I/O 契约</h2>
<p><code>zotero_cli</code> 是我做的最"硬核"的一个 CLI —— 它<strong>同时面向两种用户</strong>:人类(我自己在终端敲)和 AI agent(Claude Code 自动调)。这两种用户的 I/O 需求完全相反:</p>
<table>
<thead>
<tr>
<th>用户</th>
<th>输入偏好</th>
<th>输出偏好</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>人类</strong></td>
<td>自然语言、模糊关键词</td>
<td>表格、彩色、有进度条</td>
</tr>
<tr>
<td><strong>AI agent</strong></td>
<td>结构化字段、严格 schema</td>
<td>JSON / YAML,无装饰</td>
</tr>
</tbody>
</table>
<p>如果我写两份代码,噩梦。如果我写一份"人类友好"的代码,agent 拿到一堆 ANSI 颜色码要花 30 行代码 strip 掉。</p>
<p>正确做法:<strong>结构化数据是源头,人类友好的渲染是装饰层</strong>。看 <code>zotero_cli</code> 的 find 命令怎么做的:</p>
<pre><code class="language-go">// zotero_cli/internal/cli/parse_args_find.go
type findParsedArgs struct {
    Opts          backend.FindOptions
    JSONOutput    bool      // ←--json
    Snippet       bool      // ←--snippet
    QueryProvided bool
}

func parseFindArgs(args []string) (findParsedArgs, error) {
    for i := 0; i &#x3C; len(args); i++ {
        switch args[i] {
        case "--json":
            jsonOutput = true
        case "--snippet":
            snippet = true
        // ... 业务参数
        }
    }
}
</code></pre>
<p>每一条命令都有 <code>--json</code> 开关。<strong>默认输出是面向人类的(表格 + 颜色)</strong>,<strong><code>--json</code> 一开,只输出结构化数据</strong>:</p>
<pre><code class="language-bash"># 人类用
$ zot find "CRISPR" --fulltext
┌─────┬──────────────────────┬────────┬──────┐
│ Key │ Title                │ Year   │ Cite │
├─────┼──────────────────────┼────────┼──────┤
│ A1B │ CRISPR-Cas9 in 2024  │ 2024   │ 142  │
└─────┴──────────────────────┴────────┴──────┘

# Agent 用
$ zot find "CRISPR" --fulltext --json
[
  {"key":"A1B","title":"CRISPR-Cas9 in 2024","year":2024,"citations":142}
]
</code></pre>
<blockquote>
<p>💡 面向人类的"漂亮"是<strong>装饰</strong>层,面向 agent 的"可解析"是<strong>契约</strong>层。装饰是给契约加注释,而不是相反。</p>
</blockquote>
<h3>4.1 为什么这模式在 Claude Agent SDK 生态特别重要</h3>
<p>Claude Code(以及 Codex、Cursor)读你的 CLI 时,<strong>第一步是把 stdout 喂给 LLM</strong>。如果 stdout 是带 ANSI 颜色码的表格:</p>
<ol>
<li>你的 CLI 要先去掉颜色码(<code>strip-ansi</code>),否则 token 数翻 3 倍</li>
<li>LLM 要花额外的 reasoning 理解表格结构,准确性下降</li>
<li>输出的 JSON 经常被表格渲染破坏 —— 你以为 schema 严格,实际上 <code>--json</code> 在某个分支没生效</li>
</ol>
<p><code>--json</code> 一开,这些问题全消失。<strong><code>zotero_cli</code> 的 README 里我把这条放在"AI Agent 集成"小节第一条。</strong></p>
<h3>4.2 一对相关 flag:<code>--snippet</code> 和 <code>--fulltext</code></h3>
<p>为 agent 优化还要做一件事:<strong>给 agent 提供"按需展开"的开关</strong>,而不是一次性把全量数据倒给它。</p>
<pre><code class="language-bash"># 第一步:只给标题和 key,让 agent 决定读哪些
$ zot find "CRISPR" --json --snippet
[{"key":"A1B","title":"CRISPR-Cas9...","snippet":"gene editing..."}]

# 第二步:agent 选定 key 后,再拉全文
$ zot show A1B --json --fulltext
{"key":"A1B","title":"...","fulltext":"...30 KB..."}
</code></pre>
<p>这两步的<strong>总 token 比直接 <code>--full --json</code> 少一个数量级</strong>。这是 agent 时代的"分页",传统 CLI 不会这么设计。</p>
<h3>4.3 Skills 文档是对契约的补充</h3>
<p>最后说一句:<code>zotero_cli</code> 在 <code>.claude/skills/</code> 下放了 Skills 文档(自动发现机制),告诉 Claude Code 哪些命令支持 <code>--json</code>、哪些支持 <code>--fulltext</code>。<strong>CLI 的契约 + Skills 的元数据 = agent 完整可消费。</strong> 这是 SDK 生态的一个隐藏宝藏,后面会单独写一篇。</p>
<hr>
<h2>5. 模式 3:Schema 即 Prompt —— 让 Pydantic <code>Field(description=)</code> 双源合一</h2>
<p><code>manim-agent</code> 给我看到一个极其优雅的解法,值得所有 agent 项目抄作业。</p>
<h3>5.1 传统做法:提示词和 schema 两套</h3>
<p>大多数 agent 项目的输出结构,长这样:</p>
<pre><code class="language-python"># 提示词
SYSTEM_PROMPT = """
请分析这个仓库并返回 JSON,格式如下:
{
  "build_spec": {
    "mode": "短模式标签,例如 proof-walkthrough",
    "learning_goal": "单句学习目标",
    "beats": [
      {
        "id": "如 beat_001_intro",
        "title": "人类可读的小节标题",
        "narration_intent": "...",
        "target_duration_seconds": 数字
      }
    ]
  }
}
"""

# 校验
def validate_output(raw_json: str) -> dict:
    return json.loads(raw_json)  # 只能相信 prompt
</code></pre>
<p><strong>痛点</strong>:如果 <code>beats</code> 字段名改成了 <code>sections</code>,提示词要改、schema 要改、文档要改、单元测试要改。<strong>4 个地方,1 个改错就翻车。</strong></p>
<h3>5.2 manim-agent 的解法:Field(description=) 一行字当两份用</h3>
<p>看 <code>manim-agent/src/manim_agent/schemas/phase1_planning.py</code>:</p>
<pre><code class="language-python">class BuildSpecBeat(BaseModel):
    id: str = Field(description="Stable beat identifier such as beat_001_intro.")
    title: str = Field(description="Human-readable beat title.")
    visual_goal: str = Field(description="Primary visual outcome for this beat.")
    narration_intent: str = Field(description="Short narration intent aligned to this beat only.")
    target_duration_seconds: float = Field(
        ge=0,
        description="Target beat duration in seconds.",
    )
    required_elements: list[str] = Field(
        default_factory=list,
        description="Visual elements that must be present for this beat.",
    )
</code></pre>
<p>注意每一行 <code>Field(description=...)</code>。这一行字,既是:</p>
<ol>
<li><strong>Pydantic 的字段文档</strong>(给开发者看的)</li>
<li><strong>喂给 SDK 的 JSON Schema 的字段描述</strong>(给 LLM 看的)</li>
</ol>
<p>怎么做到?因为 Claude Agent SDK 的 <code>output_format</code> 参数<strong>直接收 Pydantic 模型</strong>。看 <code>schemas/__init__.py</code> 里的注释:</p>
<blockquote>
<p><em>"The installed claude_agent_sdk expects the schema at the top-level"</em></p>
</blockquote>
<p>意思是说:你定义一个 Pydantic 模型,直接传给 SDK,SDK 内部会做两件事:</p>
<ol>
<li>把模型转成 JSON Schema,作为对 LLM 的输出约束</li>
<li>用 Pydantic 校验 LLM 的输出,失败时自动 retry</li>
</ol>
<p><strong>提示词不是手写的,是从 schema 自动生成的。</strong> 这就是"Schema 即 Prompt"。</p>
<h3>5.3 注册中心:让所有 phase 的 schema 可发现</h3>
<p><code>manim-agent</code> 一共 5 个 phase,每个 phase 都有自己的输出 schema。<code>PhaseSchemaRegistry</code> 统一管理:</p>
<pre><code class="language-python"># manim-agent/src/manim_agent/schemas/__init__.py
class PhaseSchemaRegistry:
    _SCHEMAS: dict[str, type[Phase1PlanningOutput | Phase2ScriptDraftOutput | ...]]

    @classmethod
    def output_format_schema(cls, phase: str) -> dict:
        """Get JSON schema for a specific phase (喂给 SDK)."""
        model = cls.get_model(phase)
        return model.model_json_schema()

    @classmethod
    def get_model(cls, phase: str):
        return cls._SCHEMAS[phase]
</code></pre>
<p>业务代码查这个注册中心就能拿到任何 phase 的 schema,<strong>不用关心 schema 存在哪个文件</strong>。同时这个 schema 既能:</p>
<ul>
<li>喂给 SDK 当 <code>output_format</code></li>
<li>在 dispatcher 里做运行时校验</li>
<li>在前端用 ajv 校验返回的 JSON</li>
<li>在 CI 里做回归测试</li>
</ul>
<blockquote>
<p>💡 <strong>同一份 schema,被 SDK 用、被前端用、被 CI 用、被文档用、被单元测试用 —— 单一数据源。</strong></p>
</blockquote>
<h3>5.4 反向用法:从 SDK 拿到 schema 后,前端能省一半代码</h3>
<p>这个洞察我反复用了 3 个项目:</p>
<ol>
<li>后端 Pydantic 模型定义了 <code>BuildSpec</code>(包含 <code>beats: list[BuildSpecBeat]</code>)</li>
<li><code>BuildSpec.model_json_schema()</code> 自动生成标准 JSON Schema</li>
<li>前端 TypeScript 直接用 <code>json-schema-to-typescript</code> 转成 <code>.d.ts</code> 类型</li>
<li>前端 ajv 在运行时校验任何从 API 来的 JSON</li>
</ol>
<p><strong>以前:</strong> 前端手写 TS interface,后端手写 Pydantic,后端手写 prompt 提示词,3 份重复代码,改一处要同步 3 处。
<strong>现在:</strong> 后端写一份 Pydantic,3 个地方自动同步,改一处生效 3 处。</p>
<p><code>manim-agent</code> 的 dispatcher 注释里有一句特别精炼的总结:</p>
<blockquote>
<p><em>"消费 Claude Agent SDK 消息流,提取结构化信息并输出实时日志。"</em></p>
</blockquote>
<p>SDK 给的是 <code>StreamEvent</code>,里面嵌着 <code>ToolUseBlock</code>、<code>TextBlock</code>、<code>ThinkingBlock</code>,需要<strong>根据 phase 的 schema 把内容解析成强类型</strong>。<code>Field(description=)</code> 让这一步零成本。</p>
<h3>5.5 这模式的可移植性</h3>
<p>Schema 即 Prompt 在 Claude Agent SDK 上是<strong>一等公民</strong>(SDK 的 <code>output_format</code> 原生支持)。在 LangGraph / Agno 上<strong>也能用</strong>,但需要自己写一个"把 Pydantic 转成 provider-specific schema"的小适配器 —— 通常 30 行代码搞定,但要做一次。</p>
<blockquote>
<p>🎯 如果你的 agent 项目里,提示词和输出 schema 在两个地方维护,<strong>那就是反模式</strong>。无论用什么 SDK,都应该用这个模式把两份合一份。</p>
</blockquote>
<hr>
<h2>6. 模式 4:工具设计三原则 —— 单一职责 / 幂等 / 可逆</h2>
<p><code>biotools_agent</code> 上线半年后,我把它的工具集拆过 3 次。前两次拆错了,第三次才稳下来。</p>
<h3>6.1 单一职责:一个工具做一件事,且只做一件事</h3>
<p>第一个项目里我犯的错是给 agent 装大而全的工具,比如 <code>analyze_repo</code> 一口气返回项目元数据 + 代码质量评分 + 安全扫描结果 + 性能特征。后来想加"只跑安全扫描"的入口,改不动 —— 因为这函数被 4 个调用方依赖。</p>
<p><code>biotools_agent</code> 重构后,工具按"能返回的字段"切分,而不是按"业务场景"切分:</p>
<pre><code class="language-python"># 错误示范(我自己写的,现在看是反模式)
@tool
def analyze_repo(url: str) -> dict:
    """返回项目的所有维度分析。"""
    return {"meta": ..., "quality": ..., "security": ..., "perf": ...}

# 正确切分
@tool
def get_repo_metadata(url: str) -> RepoMetadata: ...
@tool
def run_security_scan(url: str) -> SecurityReport: ...
@tool
def run_quality_analysis(url: str) -> QualityReport: ...
</code></pre>
<p>为什么?Agent 决定"我下一步要不要看 security"时,<strong>输入越窄它越准</strong>。给它一个返回 17 个字段的大对象,LLM 经常忘记某个字段存在;给它一个返回 3 个字段的小对象,它每次都看完整。</p>
<blockquote>
<p>💡 如果一个工具的 docstring 包含"和"、"以及"、"或者",它大概率违反单一职责。</p>
</blockquote>
<h3>6.2 幂等:Agent 会反复调,工具要能扛住</h3>
<p>Agent 跑长任务时,<strong>重复调用是常态</strong>:网络超时重试、Agent 自己 forgot 上下文、人为 retry。<code>zotero_cli</code> 的 <code>add-tag</code> 工具第一版设计:</p>
<pre><code class="language-bash"># 第一版:zot add-tag --items K1 --tag "to-read"
# 重复执行:tag 出现多次?数据库报唯一键冲突?Agent 报错?
</code></pre>
<p>正确做法是 <strong>set semantics</strong>(把 tag 当集合,不重复添加):</p>
<pre><code class="language-go">// zotero_cli/internal/backend/tags.go (伪代码示意)
func AddTag(items []string, tag string) error {
    for _, item := range items {
        // 用 INSERT ... ON CONFLICT DO NOTHING,确保重复调用安全
        _, err := db.Exec(`
            INSERT INTO item_tags (item_key, tag) VALUES (?, ?)
            ON CONFLICT DO NOTHING
        `, item, tag)
        if err != nil { return err }
    }
    return nil
}
</code></pre>
<p>调用一次、调用十次,结果一致。<strong>Agent 写错了循环、写了 retry,都不用你担心。</strong></p>
<blockquote>
<p>⚠️ <strong>幂等检查清单</strong>:</p>
<ul>
<li>工具读操作:天然幂等</li>
<li>工具写操作:必须有 <code>ON CONFLICT</code> / <code>WHERE NOT EXISTS</code> / 版本号检查</li>
<li>工具发外部副作用(发邮件、扣款、调外部 API):<strong>没法幂等就别暴露给 agent</strong></li>
</ul>
</blockquote>
<h3>6.3 可逆:写操作默认禁删,版本号乐观锁</h3>
<p>最后一条最关键 —— 也是 agent 时代独有的新规则。</p>
<p>传统 API 设计的金科玉律是"CRUD 全开"。但当<strong>调用方是 LLM 而不是人</strong>时,<code>DELETE</code> 是定时炸弹。LLM 看到一条记录,觉得它"应该被删",就调了 <code>DELETE</code> —— 它不知道这条记录有 7 个外键关联。</p>
<p><code>zotero_cli</code> 的处理是<strong>写操作分层</strong>:</p>
<ul>
<li><strong>读操作</strong>:<code>find</code> / <code>show</code> / <code>export</code> —— 全开,无限制</li>
<li><strong>增量写</strong>:<code>add-tag</code> / <code>update</code> —— 允许,但带版本号乐观锁</li>
<li><strong>危险写</strong>:<code>delete</code> / <code>remove-tag</code> / <code>move</code> —— <strong>默认关闭</strong>,需要显式 <code>--allow-destructive</code> 才生效</li>
</ul>
<pre><code class="language-bash"># 默认拒绝
$ zot remove-tag --items K1 --tag "old"
✗ 拒绝:删除操作需要显式 --allow-destructive 标志
  这是为 AI agent 安全设计的:防止误删

# 显式开启(警告醒目)
$ zot remove-tag --items K1 --tag "old" --allow-destructive
⚠ 警告:此操作不可逆,确认? [y/N] y
✓ done
</code></pre>
<p>版本号乐观锁的代码模式:</p>
<pre><code class="language-python"># 伪代码
def update_item(key: str, fields: dict, expected_version: int) -> Result:
    """如果当前 version != expected_version,拒绝更新(避免覆盖并发修改)。"""
    affected = db.execute("""
        UPDATE items SET version = version + 1, fields = ?
        WHERE key = ? AND version = ?
    """, fields, key, expected_version)

    if affected == 0:
        return Result(success=False, error="version_conflict,需要重读")
    return Result(success=True, new_version=expected_version + 1)
</code></pre>
<p><strong>Agent 拿到 <code>version_conflict</code> 错误,正确的行为是重读 + 重试,不会盲目覆盖</strong>。</p>
<blockquote>
<p>🔥 <strong>军规</strong>:任何写操作,先问自己三遍:"LLM 调错了我能 undo 吗?Agent 写错了循环我能发现吗?并发跑两个 agent 互相覆盖了我能侦测吗?"任何一个答否,这个工具就不该暴露给 agent。</p>
</blockquote>
<hr>
<h2>7. 模式 5:多 agent 编排的两种姿势 —— fan-out vs pipeline</h2>
<p>第 5 个项目之后,我开始做"多 agent"类系统(<code>TrumanWorld</code>、<code>werewolf</code>、<code>IssueLab</code>、<code>mind</code>)。所有项目都是这两种姿势的组合或变体。</p>
<h3>7.1 姿势 A:fan-out 并行 —— Agent 各自独立,主 agent 汇总</h3>
<p>典型场景:<code>TrumanWorld</code>(小镇多 AI 居民)—— 每个 AI 居民独立思考、独立行动,主 agent 周期性"巡场"汇总状态。</p>
<pre><code class="language-python"># TrumanWorld 风格(伪代码)
async def tick(self):
    # 1. 广播当前世界状态给所有 agent
    world_state = await self.get_world_state()

    # 2. 并行触发所有 agent 思考
    decisions = await asyncio.gather(*[
        agent.observe_and_decide(world_state)
        for agent in self.agents
    ])

    # 3. 仲裁 + 应用
    for agent, decision in zip(self.agents, decisions):
        if self.arbiter.allows(decision):
            await self.world.apply(agent, decision)
</code></pre>
<p><strong>特征</strong>:</p>
<ul>
<li>每个 agent 独立思考,互不调用</li>
<li>主 agent 做仲裁(决定哪个 decision 落地)</li>
<li>适合"对同一个输入有多个视角"的场景</li>
<li>加 agent 改一行 <code>gather</code> 的列表</li>
</ul>
<p><strong>Claude Agent SDK 对应物</strong>:<code>Task</code> 工具,主 agent 委派子任务给子 agent。</p>
<h3>7.2 姿势 B:pipeline 串行 —— 上一阶段的输出是下一阶段的输入</h3>
<p>典型场景:<code>manim-agent</code> 的 5 阶段流水线 —— phase1 规划 → phase2 写代码 → phase3 渲染审查 → phase3.5 旁白 → phase4 TTS → phase5 合成。</p>
<pre><code class="language-python"># manim-agent 风格
async def run_pipeline(self, user_input: str) -> Video:
    spec = await self.phase1_planning(user_input)         # → BuildSpec
    code = await self.phase2_implementation(spec)          # → RenderedSegments
    review = await self.phase3_render_review(code)         # → FrameAnalysis
    narration = await self.phase3_5_narration(spec, review)
    audio = await self.phase4_tts(narration)               # → AudioFiles
    return await self.phase5_mux([code, audio])            # → FinalVideo
</code></pre>
<p><strong>特征</strong>:</p>
<ul>
<li>强顺序:后一步消费前一步的输出</li>
<li>每一步有独立的 schema(就是模式 3 的 PhaseSchemaRegistry)</li>
<li>适合"流程可拆解、阶段可独立测试"的场景</li>
<li>每一步可以 mock 掉前一步的输出单独跑</li>
</ul>
<p><strong>Claude Agent SDK 对应物</strong>:<code>query()</code> 多次调用,每次用上一轮的 <code>ResultMessage</code> 作下一轮的 context。</p>
<h3>7.3 怎么选:任务独立性 vs 强依赖</h3>
<table>
<thead>
<tr>
<th>维度</th>
<th>fan-out</th>
<th>pipeline</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>任务独立性</strong></td>
<td>强(每个 agent 独立)</td>
<td>弱(必须按序)</td>
</tr>
<tr>
<td><strong>并行度</strong></td>
<td>高(N 倍加速)</td>
<td>1(没法并)</td>
</tr>
<tr>
<td><strong>状态复杂度</strong></td>
<td>简单(每 agent 独立)</td>
<td>复杂(要管中间产物)</td>
</tr>
<tr>
<td><strong>debug 难度</strong></td>
<td>低(可单跑任一 agent)</td>
<td>中(需重放上游)</td>
</tr>
<tr>
<td><strong>适合场景</strong></td>
<td>模拟、评审、并行尝试</td>
<td>流程化生产、ETL</td>
</tr>
<tr>
<td><strong>典型项目</strong></td>
<td>TrumanWorld, werewolf</td>
<td>manim-agent, gearbox</td>
</tr>
</tbody>
</table>
<p><strong>混合使用是常态</strong>。<code>manim-agent</code> 的 phase3 渲染审查里,内部就是 fan-out:同时跑 "美学评估 agent" + "技术正确性 agent" + "可读性 agent",汇总成 <code>FrameAnalysis</code>。<strong>外层 pipeline,内层 fan-out</strong> —— 这是经典组合。</p>
<h3>7.4 SDK 内的"轻量多 agent"</h3>
<p>如果你不需要 TrumanWorld 那种强模拟,只想"让一个 agent 委派子任务给另一个",Claude Agent SDK 的 <code>Task</code> 工具是<strong>最轻量的解法</strong>:</p>
<pre><code class="language-python"># SDK 内的委派(伪代码)
@tool
async def delegate_research(topic: str) -> str:
    """把研究任务委派给子 agent。"""
    async for msg in query(
        prompt=f"研究 {topic} 的最新进展,返回 3 条关键信息",
        options=ClaudeAgentOptions(model="haiku")  # 子 agent 用更便宜的模型
    ):
        if isinstance(msg, ResultMessage):
            return msg.result
</code></pre>
<p><strong>主 agent 决定何时委派,子 agent 跑独立任务,主 agent 拿结果</strong>。这就是 80% 项目需要的"多 agent",不需要 asyncio.gather 也不需要 message bus。</p>
<hr>
<h2>8. 模式 6:事件先于功能 —— 9-type EventType + JSONL EventStore</h2>
<p><code>manim-agent</code> 上线第 2 周,我加了 <code>LogViewer</code> 前端组件,想"实时显示 agent 在做什么"。然后发现:agent 的 <code>StreamEvent</code> 流里有 thinking、tool_use、tool_result、text、error 各种类型,<strong>如果直接转发给前端,前端要写一堆 <code>if/else</code> 分支,改一个事件类型要同步前后端</strong>。</p>
<p>我<strong>花了 3 天</strong>把整个事件系统重构。</p>
<h3>8.1 9 个事件类型,1 个枚举,1 个 payload 模型</h3>
<pre><code class="language-python"># manim-agent/src/manim_agent/pipeline_events.py
class EventType(str, Enum):
    LOG = "log"               # 纯文本日志(向后兼容)
    STATUS = "status"           # 任务状态变更
    ERROR = "error"             # 错误
    TOOL_START = "tool_start"     # 工具调用开始
    TOOL_RESULT = "tool_result"   # 工具调用完成
    THINKING = "thinking"         # 思考/推理块
    PROGRESS = "progress"         # 进度(token/轮次/耗时)
    TRACE_SPAN = "trace_span"     # Trace/Span 进入或退出
    PHASE_BOUNDARY = "phase_boundary"  # Phase 边界
</code></pre>
<p><strong>9 个不多不少</strong>。少了:会有事件类型被勉强归到 "LOG" 里变模糊;多了:前端 switch case 写到手抽筋。</p>
<p>每个类型有它<strong>专属的 Pydantic payload 模型</strong>:</p>
<pre><code class="language-python">class ToolStartPayload(BaseModel):
    tool_use_id: str
    name: str                              # Write / Bash / Edit / Read ...
    input_summary: dict[str, Any]

class ToolResultPayload(BaseModel):
    tool_use_id: str                       # ← 配对到 tool_start
    name: str = ""
    is_error: bool = False
    content: Optional[str] = None
    duration_ms: Optional[int] = None      # ← 关键:耗时统计
    error_type: Optional[str] = None       # ← execution / permission / timeout / interrupt

class ProgressPayload(BaseModel):
    turn: int
    total_tokens: int
    tool_uses: int
    elapsed_ms: int
    estimated_cost_cny: Optional[float]    # ← 费用估算
    # ... 各种 token / cost 字段
</code></pre>
<p><code>PipelineEvent</code> 是<strong>统一的包装</strong>,所有事件都走同一条数据通路:</p>
<pre><code class="language-python">class PipelineEvent(BaseModel):
    event_id: str
    type: EventType
    timestamp_ms: int
    task_id: str                           # ← 多任务隔离
    phase: Optional[str] = None
    payload: dict[str, Any]                # ← 上面那些 payload 序列化后塞这里
</code></pre>
<blockquote>
<p>💡 <strong>事件类型是封闭枚举,具体 payload 用 dict[str, Any]</strong>。这平衡了"类型安全"(enum 不会拼错)和"灵活扩展"(加新字段不改 enum)。</p>
</blockquote>
<h3>8.2 JSONL append-only 持久化</h3>
<p>事件不能只活在内存里 —— agent 跑完一个长任务,前端断开、用户刷新,<strong>所有上下文就丢了</strong>。<code>manim-agent</code> 的 <code>EventStore</code> 把每个事件追加到 <code>{task_id}.jsonl</code> 文件:</p>
<pre><code class="language-python"># manim-agent/src/manim_agent/event_store.py
class EventStore:
    def __init__(self, store_dir: str | Path = "events"):
        self._store_dir = Path(store_dir)
        self._store_dir.mkdir(parents=True, exist_ok=True)
        self._counts: dict[str, int] = {}
        self._lock = threading.Lock()

    def append(self, task_id: str, event: PipelineEvent) -> None:
        path = self._store_dir / f"{task_id}.jsonl"
        serialized = event.model_dump_json(by_alias=True)
        with self._lock:
            with open(path, "a", encoding="utf-8") as f:
                f.write(serialized + "\n")
            self._counts[task_id] = self._counts.get(task_id, 0) + 1
</code></pre>
<p><strong>为什么 JSONL 而不是 SQLite</strong>?</p>
<ul>
<li><strong>append-only</strong> 永远 O(1),不需要事务</li>
<li><strong>每行一个事件</strong>,grep 友好(<code>grep "TOOL_RESULT" events/abc.jsonl | wc -l</code>)</li>
<li><strong>跨语言友好</strong>:任何语言都能读 JSONL,SQLite 还要 driver</li>
<li><strong>天然支持 tail</strong>:前端的 LogViewer 用 SSE 或长轮询,服务端 <code>tail -f</code> 即可</li>
</ul>
<p><strong>3 个项目以后</strong>,我所有 agent 项目都用这个模式。<strong>事件文件</strong>成了 agent 的"事实日志",比 SDK 自己的 transcript 干净 10 倍。</p>
<h3>8.3 dispatcher:把 SDK 流翻译成内部事件</h3>
<p>最后一个关键模块是 dispatcher —— 它消费 SDK 的 <code>StreamEvent</code>,翻译成 <code>PipelineEvent</code> 推给 EventStore 和 SSE。</p>
<pre><code class="language-python"># manim-agent/src/manim_agent/dispatcher.py
from claude_agent_sdk import (
    AssistantMessage, StreamEvent, TextBlock,
    ThinkingBlock, ToolUseBlock, ToolResultBlock,
)

async def dispatch_message(self, msg: Message, task_id: str):
    if isinstance(msg, AssistantMessage):
        for block in msg.content:
            if isinstance(block, TextBlock):
                await self.emit(task_id, EventType.LOG, {"text": block.text})
            elif isinstance(block, ThinkingBlock):
                await self.emit(task_id, EventType.THINKING, {
                    "thinking": block.thinking,
                    "preview": block.thinking[:100] + "...",
                })
            elif isinstance(block, ToolUseBlock):
                await self.emit(task_id, EventType.TOOL_START, {
                    "tool_use_id": block.id,
                    "name": block.name,
                    "input_summary": self._summarize(block.input),
                })
            elif isinstance(block, ToolResultBlock):
                await self.emit(task_id, EventType.TOOL_RESULT, {
                    "tool_use_id": block.tool_use_id,
                    "content": self._truncate(block.content, max=2000),
                    "is_error": block.is_error,
                })
</code></pre>
<p><strong>dispatcher 之外没有任何模块直接 import SDK 消息类型</strong>。这条边界把"SDK 协议层"和"业务事件层"干净分开。</p>
<blockquote>
<p>🎯 <strong>SDK 升级时不破坏业务</strong>。SDK 0.x 升到 1.0,消息格式变了?改 dispatcher 一个文件就完事,业务模块一个字符都不动。</p>
</blockquote>
<h3>8.4 这模式为什么是 Claude Agent SDK 特色</h3>
<p><code>StreamEvent</code> 把 thinking、tool_use、tool_result、text 全部结构化吐出来 —— 这是 Claude Agent SDK 相对 LangGraph / Agno 的关键差异化优势。其他框架要么只给 final result(没过程可观测),要么要你装额外的 tracing 库。<strong>SDK 协议层 + dispatcher + 9-type 事件 + JSONL 持久化 = 开箱即用的可观测性</strong>。</p>
<hr>
<h2>9. 模式 7:Trace/Span 让 Agent 透明 —— OpenTelemetry 风格</h2>
<p>模式 6 解决了"agent 在做什么"的细粒度可观测。模式 7 解决"<strong>哪些步骤是同一个请求</strong>"和"<strong>调用链长什么样</strong>"的粗粒度可观测。</p>
<h3>9.1 为什么需要 trace 模型</h3>
<p><code>manim-agent</code> 的 pipeline 有 5 个 phase,每个 phase 内部又跑 10+ 个 tool call。<strong>出问题时用户问:"我的视频生成任务卡哪了?"</strong> 如果只有事件流,你看到一个 <code>TOOL_START: Write</code> 后面什么都没有 —— 不知道这是 phase2 还是 phase3,不知道是不是 phase1 的计划被搞砸了,不知道根因在哪儿。</p>
<p><code>manim-agent</code> 引入了 OpenTelemetry 风格的 trace/span 模型:</p>
<pre><code class="language-python"># manim-agent/src/manim_agent/pipeline_trace.py
@dataclass
class TraceSpan:
    trace_id: str                          # ← 整次任务的唯一 ID
    span_id: str = ""
    parent_span_id: Optional[str] = None   # ← 父子关系
    name: str = ""
    phase: Optional[str] = None
    start_ms: int
    end_ms: Optional[int] = None
    status: SpanStatus = SpanStatus.OK      # ok / error / cancelled
    tags: dict[str, Any] = field(default_factory=dict)
</code></pre>
<p><strong>关键设计</strong>:</p>
<ul>
<li><code>trace_id</code> 整次任务唯一(UUID4)</li>
<li><code>span_id</code> 单个区间唯一,可嵌套(<code>parent_span_id</code> 串成树)</li>
<li><code>start_ms</code> / <code>end_ms</code> / <code>duration_ms</code> 自动计算</li>
<li><code>tags</code> 是开放的 metadata(<code>{"phase": "phase1_planning", "model": "sonnet"}</code>)</li>
</ul>
<h3>9.2 span_context():让 trace 不打扰业务</h3>
<p>手动管 <code>span.close()</code> 太烦,<code>manim-agent</code> 写了 <code>span_context()</code> 上下文管理器:</p>
<pre><code class="language-python"># 用法
with span_context(trace_id=trace_id, name="phase1_planning", phase="phase1") as span:
    spec = await self.phase1_planning(user_input)
    span.set_tag("beat_count", len(spec.build_spec.beats))
# span 自动 close,自动发射 TRACE_SPAN 事件
</code></pre>
<p><strong>背后机制</strong>:基于 <code>ContextVar</code> 而不是全局变量,<strong>协程/异步任务安全</strong>:</p>
<pre><code class="language-python"># pipeline_trace.py
_current_span: contextvars.ContextVar[Optional[TraceSpan]] = ContextVar(
    "current_span", default=None
)

@contextlib.contextmanager
def span_context(trace_id: str, name: str, **tags):
    parent = _current_span.get()
    parent_id = parent.span_id if parent else None

    span = TraceSpan(
        trace_id=trace_id,
        parent_span_id=parent_id,
        name=name,
        tags=tags,
    )
    token = _current_span.set(span)
    try:
        yield span
    except Exception as e:
        span.close(status=SpanStatus.ERROR)
        span.set_tag("error", str(e))
        raise
    finally:
        if span.end_ms is None:
            span.close()
        _current_span.reset(token)
        emit_event(span)  # ← 自动发到 EventStore
</code></pre>
<p><strong>上下文管理器内嵌上下文管理器,自动形成父子树</strong>:</p>
<pre><code class="language-python">with span_context(trace_id=trace, name="phase3_render_review", phase="phase3") as outer:
    with span_context(trace_id=trace, name="aesthetic_check") as inner1:
        ...  # inner1.parent_span_id == outer.span_id
    with span_context(trace_id=trace, name="technical_check") as inner2:
        ...  # inner2.parent_span_id == outer.span_id
</code></pre>
<h3>9.3 前端一棵树,后端一棵树</h3>
<p>Trace 数据通过 EventStore 持久化,前端 LogViewer 拿到后能渲染成<strong>可折叠的树</strong>:</p>
<pre><code>▼ phase1_planning (1.2s, ok)
  ├─ get_repo_metadata (0.3s, ok)
  ├─ ai_analysis (0.8s, ok)
  └─ commit_spec (0.1s, ok)
▼ phase2_implementation (45.6s, ok)
  ├─ write_code (12.3s, ok)
  ├─ render_segment_1 (8.4s, ok)
  └─ render_segment_2 (9.1s, ok)
▶ phase3_render_review (in progress)
</code></pre>
<p><strong>用户问"卡哪儿了"</strong>,看树就知道:phase3 在跑,前两个 phase 都 ok,<strong>问题在 phase3 内部</strong>,继续点开 phase3 树。</p>
<h3>9.4 跟上一篇文章的关系</h3>
<p>这个 trace 模型跟 26028 钩子文讲的 <code>PreToolUse</code> / <code>PostToolUse</code> 是<strong>互补的</strong>:</p>
<ul>
<li><strong>钩子</strong>(26028) → 拦截/改写 SDK 行为</li>
<li><strong>Trace</strong>(本篇) → 观测 SDK 行为</li>
</ul>
<p><code>manim-agent</code> 的钩子层(详见 <code>hooks.py</code>)就是从 <code>claude_agent_sdk.types</code> 导入 <code>PreToolUseHookSpecificOutput</code> 等,<strong>配合 trace 一起工作</strong>:钩子拦截到危险命令时,既 deny SDK,又发一个 <code>TRACE_SPAN</code> 事件(<code>status=ERROR, tags={"reason": "dangerous_command"}</code>)。<strong>这是 SDK 给的"组合拳"</strong>,大部分框架没有这么干净的对接。</p>
<hr>
<h2>10. 模式 8:配置外置 + 环境矩阵</h2>
<p>第 7 个项目以后,我把所有 agent 项目的配置分成三层,跨项目复用:</p>
<h3>10.1 三层配置</h3>
<pre><code class="language-python"># biotools_agent/src/config.py
class AppConfig:
    # 第 1 层:硬编码默认值(代码里)
    DEFAULT_MODEL = "sonnet"
    DEFAULT_MAX_TOKENS = 4096
    DEFAULT_TIMEOUT_S = 120

    # 第 2 层:env.example 模板(给开发者看的)
    # ANTHROPIC_API_KEY=
    # MODELSCOPE_API_KEY=
    # SUPABASE_URL=
    # SUPABASE_ANON_KEY=

    # 第 3 层:.env 文件(实际运行值,不进 git)
</code></pre>
<p><strong>为什么三层</strong>?</p>
<ul>
<li>第 1 层让代码"开箱即用",新人 clone 下来能跑</li>
<li>第 2 层让"应该配置什么"显式可见,新人不会漏</li>
<li>第 3 层让"实际值"隔离,不会泄漏到 git</li>
</ul>
<h3>10.2 三种运行环境</h3>
<p><code>biotools_agent</code> 的同一份代码跑在三种环境里,行为略有不同:</p>
<table>
<thead>
<tr>
<th>环境</th>
<th>触发</th>
<th>输出方式</th>
<th>模型</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>本地开发</strong></td>
<td><code>biotools-agent analyze &#x3C;url></code></td>
<td>本地 HTML/MD/JSON</td>
<td><code>sonnet</code></td>
<td>实时打印进度</td>
</tr>
<tr>
<td><strong>GitHub Actions</strong></td>
<td><code>workflow_dispatch</code> 触发</td>
<td>上传 Artifacts</td>
<td><code>haiku</code></td>
<td>节省成本,批量跑</td>
</tr>
<tr>
<td><strong>CI 测试</strong></td>
<td><code>npm test</code> / <code>pytest</code></td>
<td>不调 LLM,只跑单测</td>
<td>—</td>
<td>mock 掉 AIAnalyzer</td>
</tr>
</tbody>
</table>
<p><strong>关键代码</strong>:</p>
<pre><code class="language-python"># biotools_agent/src/main.py
def analyze(repo_url: str, ..., env_file: Optional[str] = None):
    if env_file:
        config = ConfigManager(env_file)
    else:
        config = config_manager
    # ... 用 config 跑后续流程
</code></pre>
<p><strong>在 GitHub Actions workflow 里</strong>(<code>biotools-analysis.yml</code>):</p>
<pre><code class="language-yaml">- name: Run analysis
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    MODELSCOPE_API_KEY: ${{ secrets.MODELSCOPE_API_KEY }}
    SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
  run: |
    biotools-agent analyze ${{ inputs.repo_url }} --output ./report
</code></pre>
<p><strong>在单测里</strong>:</p>
<pre><code class="language-python"># tests/test_github_analyzer.py
def test_github_analyzer(monkeypatch):
    # mock 掉 AIAnalyzer,不让它真调 LLM
    monkeypatch.setattr(ai_analyzer_adapter, "AIAnalyzer", MockAnalyzer)
    ...
</code></pre>
<p><strong>5 个项目以后</strong>,我的标准 starter 模板里这三种环境配置是 default include 的——<strong>新项目第一周就在正确轨道上。</strong></p>
<h3>10.3 一句话:配置跟随代码,但 values 跟随环境</h3>
<blockquote>
<p>🎯 <strong>军规延伸</strong>:<code>.env.example</code> 跟随代码 commit,<code>.env</code> 永远不进 git,<strong>环境之间的差异(模型/超时/批量)显式写在 config 而不是散在 if 分支里</strong>。</p>
</blockquote>
<hr>
<h2>11. 模式 9:从项目到飞轮 —— Agent 自己改 Agent</h2>
<p><strong>前 8 个模式是让一个项目"稳定运行"。模式 9 是让多个项目之间形成正反馈飞轮。</strong></p>
<h3>11.1 gearbox 的设计思想</h3>
<p><code>gearbox</code> 是我 2025 年初做的项目,核心想法:</p>
<pre><code>                    ┌──────────────┐
                    │              │
                    ↓              │
       GitHub Issue/PR  →  Agent 跑数据
                    │              │
                    ↓              │
              训练数据 / 反馈信号  │
                    │              │
                    ↓              │
              改进 Agent 本身  ────┘
</code></pre>
<p><strong>agent 的运行数据</strong>(成功的 PR、失败的 review、用户反馈)回流成训练数据,<strong>改 agent 本身</strong>。这是 OpenAI Devin / Claude Code / Codex 这类 AI Coding 工具的隐性壁垒——不只是模型强,是数据飞轮强。</p>
<h3>11.2 在我的项目里,这模式怎么落地</h3>
<p>不是每个项目都需要完整飞轮。但<strong>简化的飞轮</strong>任何项目都能做:</p>
<p><strong>环节 1:留痕</strong></p>
<ul>
<li><code>cc-insights</code> 收集 Claude Code 的所有 tool call,做成本/质量分析 → <strong>数据留下来</strong></li>
<li><code>biotools_agent</code> 把每次分析结果存 Supabase → <strong>领域数据留下来</strong></li>
<li><code>manim-agent</code> 的 EventStore 存所有 pipeline 事件 → <strong>过程数据留下来</strong></li>
</ul>
<p><strong>环节 2:分析</strong></p>
<ul>
<li><code>cc-insights</code> 的输出本身就是分析报告</li>
<li><code>biotools_agent</code> 可以看"哪些类型的项目分析质量差" → 识别模型弱点</li>
<li><code>manim-agent</code> 可以看"哪个 phase 最常 timeout" → 识别工程瓶颈</li>
</ul>
<p><strong>环节 3:反馈</strong></p>
<ul>
<li>把分析结论<strong>写进 agent 的 prompt</strong>(<code>biotools_agent</code> 实际有根据反馈微调过 <code>BIOTOOLS_ANALYZER_AGENT</code> 的 prompt)</li>
<li>把分析结论<strong>写进工具集</strong>(<code>zotero_cli</code> 早期没有 <code>find --fulltext</code>,从用户使用模式里发现需求才加的)</li>
</ul>
<p><strong>环节 4:循环</strong></p>
<ul>
<li>agent 跑新数据 → 产生新留痕 → 新分析 → 新反馈 → agent 更好</li>
</ul>
<h3>11.3 飞轮的最小可行实现</h3>
<p>如果你刚开始一个新项目,<strong>第一天就做这两件事</strong>:</p>
<ol>
<li><strong>每个 agent 运行都生成 event log</strong>(就是模式 6)</li>
<li><strong>每周花 1 小时看 event log</strong>,找规律</li>
</ol>
<blockquote>
<p>🔥 <strong>大多数 agent 项目死在第 6 个月,是因为开发者从来没看过自己 agent 的运行数据。</strong> 你不观察它,它就观察不了你,改进无从谈起。</p>
</blockquote>
<hr>
<h2>12. 5 条军规</h2>
<p>写完 9 个模式,最后 5 条军规收尾。这 5 条不是从框架里推出来的,是从 14 个项目里<strong>踩坑踩出来</strong>的:</p>
<blockquote>
<p><strong>1. Agent 的每个决策点都要可观测。</strong>
LLM 决定调哪个工具、传什么参数、放弃还是继续 —— 这些决策<strong>不能只活在 LLM 的 context 里</strong>。</p>
<p><strong>2. 任何写操作都要可逆或可审。</strong>
Agent 写文件、调 API、改数据库 —— 写之前想清楚能不能 undo,不能 undo 就要留 audit log(谁、何时、什么内容、为什么)。</p>
<p><strong>3. 工具宁可少而精,不要多而杂。</strong>
17 个返回大对象的工具,LLM 永远学不会用;3 个返回窄对象的工具,LLM 第一次就懂。<strong>每加一个工具,你都在替 LLM 加重上下文负担</strong>。</p>
<p><strong>4. 配置文件跟随代码,不要反过来。</strong>
代码改了配置不改,12 个环境里 11 个跑挂 —— 这是 agent 项目的头号杀手。</p>
<p><strong>5. 把"用户"和"Agent"当成两个独立消费者。</strong>
同一段输出,人类要好看、Agent 要好解析。<strong>默认输出给人类,<code>--json</code> 给 agent</strong>。</p>
</blockquote>
<p><strong>14 个项目里,这 5 条我至少在 8 个项目里违反过,每次违反都付出 2-3 周代价</strong>。如果你只能记住一件事,记住第 1 条:<strong>没观测,就没改进。</strong></p>
<hr>
<h2>13. 起步:复刻我的最小骨架</h2>
<p>如果你想从零开始一个 agent 项目,这是我 14 个项目沉淀出来的<strong>最小可工作骨架</strong>(9 个模式各对应 1-2 个文件):</p>
<pre><code>my-agent-project/
├── src/
│   ├── llm/                  # 模式 1:LLM 抽象层
│   │   ├── adapter.py         #   - 唯一允许 import SDK 的地方
│   │   └── __init__.py        #   - 对外暴露统一接口
│   ├── tools/                 # 模式 4:工具集
│   │   └── *.py               #   - 每个文件一个工具
│   ├── schemas/               # 模式 3:结构化输出
│   │   ├── __init__.py        #   - SchemaRegistry 统一查找
│   │   └── phase1_output.py   #   - Field(description=) 双源合一
│   ├── events/                # 模式 6:事件总线
│   │   ├── event_types.py     #   - EventType 枚举 + payload 模型
│   │   ├── event_store.py     #   - JSONL append-only
│   │   └── dispatcher.py      #   - SDK 消息 → 内部事件
│   ├── trace/                 # 模式 7:Trace/Span
│   │   └── span.py            #   - span_context() + ContextVar
│   ├── pipeline/              # 模式 5:多 agent 编排
│   │   └── orchestrator.py    #   - fan-out / pipeline 实现
│   ├── cli/                   # 模式 2:JSON-first CLI
│   │   └── main.py            #   - 所有命令支持 --json
│   └── config.py              # 模式 8:配置
├── .env.example               #   - 三层配置
├── tests/
│   └── test_*.py              #   - mock LLM 抽象层,只测业务
└── README.md
</code></pre>
<p><strong>约 1500-2000 行代码,这就是 14 个项目里我提炼的最小骨架。</strong></p>
<h3>起步路径</h3>
<p>如果你想动手,推荐这个顺序(我带新人的标准流程):</p>
<ol>
<li><strong>第 1 天</strong>:搭 <code>llm/adapter.py</code> + 一个最简单的 <code>claude_agent_sdk.query()</code> 调用,跑通 hello world</li>
<li><strong>第 2 天</strong>:加 <code>events/event_types.py</code> + <code>event_store.py</code>,把第 1 天的调用包成事件</li>
<li><strong>第 3 天</strong>:加 <code>schemas/</code>,做一个 <code>Field(description=)</code> 模型 + <code>output_format</code> 参数,让 LLM 输出受 schema 约束</li>
<li><strong>第 4 天</strong>:加 <code>trace/span.py</code>,用 <code>span_context()</code> 包住第 3 天的逻辑</li>
<li><strong>第 5 天</strong>:加 <code>cli/main.py</code>,写一个命令支持 <code>--json</code></li>
<li><strong>第 6-7 天</strong>:加第一个 <code>tools/*.py</code>,把它接到 CLI</li>
<li><strong>第 2 周开始</strong>:加 <code>pipeline/orchestrator.py</code>,做多阶段/多 agent</li>
<li><strong>第 2 周后</strong>:看 event log,找改进点,启动飞轮(模式 9)</li>
</ol>
<p><strong>7 天一个最小可工作 demo,2 周一个可投产的 v0.1。</strong> 这套节奏我在 4 个项目上验证过。</p>
<h3>起步骨架的开源计划</h3>
<p>我会把这套骨架抽成一个独立的 starter repo,叫 <code>agent-starter</code>(暂定名),预计 7 月初开源。届时本文会更新链接。<strong>本站已上线 RSS,订阅即可第一时间收到通知</strong>(详见 <a href="/blog/260628_git_claude_hooks">从 git hooks 到 Claude Code hooks 的范式迁移</a>)。</p>
<hr>
<h2>参考链接</h2>
<p><strong>本文涉及的 14 个 Agent 项目</strong></p>
<table>
<thead>
<tr>
<th>项目</th>
<th>模式</th>
<th>一句话</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/gqy20/biotools_agent"><code>biotools_agent</code></a></td>
<td>1、3、4、8</td>
<td>生物信息仓库深度分析,声明式 agent + LLM 抽象层典范</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/zotero_cli"><code>zotero_cli</code></a></td>
<td>2</td>
<td>AI 原生 Zotero CLI,<code>--json</code> 一等公民 + Skills 自动发现</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/manim-agent"><code>manim-agent</code></a></td>
<td>3、6、7、5</td>
<td>端到端视频生成,5 阶段 pipeline + EventStore + TraceSpan</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/cc-insights"><code>cc-insights</code></a></td>
<td>6、7</td>
<td>Claude Code 使用诊断,事件流分析 + 本地 Dashboard</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/TrumanWorld"><code>TrumanWorld</code></a></td>
<td>5</td>
<td>AI 居民小镇,fan-out 风格多 agent 模拟</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/werewolf"><code>werewolf</code></a></td>
<td>5</td>
<td>Claude Code 狼人杀 GM,跨实例编排</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/werewolf-agents"><code>werewolf-agents</code></a></td>
<td>5</td>
<td>TypeScript 版多 agent 狼人杀</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/IssueLab"><code>IssueLab</code></a></td>
<td>5</td>
<td>GitHub Issues 多智能体科研讨论网络</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/mind"><code>mind</code></a></td>
<td>5</td>
<td>AI agents 协作创新</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/gearbox"><code>gearbox</code></a></td>
<td>9</td>
<td>GitHub 仓库自动化飞轮系统</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/bx-claim-demo"><code>bx-claim-demo</code></a></td>
<td>§2 对照</td>
<td>LangGraph + FastAPI 车险理赔(框架对照)</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/gene-family-agent"><code>gene-family-agent</code></a></td>
<td>§2 对照</td>
<td>Agno 框架农学基因家族分析(框架对照)</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/cc_plugins"><code>cc_plugins</code></a></td>
<td>钩子</td>
<td>Claude Code 插件集合(模式 6 钩子)</td>
</tr>
<tr>
<td><a href="https://github.com/gqy20/Skills_demo"><code>Skills_demo</code></a></td>
<td>§2</td>
<td>Claude Code Skills 演示</td>
</tr>
</tbody>
</table>
<p><strong>关联阅读 / 外部参考</strong></p>
<ul>
<li><a href="/blog/260628_git_claude_hooks">从 git hooks 到 Claude Code hooks 的范式迁移</a> —— 模式 6/7 钩子部分的前置知识</li>
<li><a href="/blog/260627_rag_kb_survey">2026 开源知识库 RAG 方案深度调研</a> —— 14 个项目里 8 个用到了 RAG,选型逻辑见此文</li>
<li><a href="https://docs.claude.com/en/api/agent-sdk/overview">Claude Agent SDK 官方文档</a> —— API 真相之源</li>
<li><a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry Trace/Span 模型</a> —— 模式 7 的设计参照</li>
<li><a href="https://docs.pydantic.dev/latest/concepts/fields/">Pydantic Field</a> —— 模式 3 的双源合一基石</li>
<li><a href="/rss.xml">本文 RSS / Atom feed</a> —— 后续 <code>agent-starter</code> 开源时第一时间通知</li>
</ul>
]]></content:encoded>
            <author>Qingyu Ge</author>
            <category>Claude Agent SDK</category>
            <category>Agent</category>
            <category>架构</category>
            <category>工程实践</category>
            <category>可观测性</category>
        </item>
        <item>
            <title><![CDATA[当 AI 开始写代码,hooks 该挂在哪里?——从 git hooks 到 Claude Code hooks 的范式迁移]]></title>
            <link>https://home.gqy20.top/blog/260628_git_claude_hooks</link>
            <guid isPermaLink="false">260628_git_claude_hooks</guid>
            <pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[git hooks 把逻辑挂进版本控制的生命周期,Claude Code hooks 把它挂进 AI 代理的生命周期——两者同源异流。本文从 git hooks 本质讲起,讲清为什么 AI Coding 时代必须有 hooks,再用我真实的 AI_PAD 全事件上报 + cc-insights 可视化系统,拆解如何给 Claude Code 装一套「Agent 的 APM」。]]></description>
            <author>Qingyu Ge</author>
            <category>Claude Code</category>
            <category>Git Hooks</category>
            <category>Agent</category>
            <category>可观测性</category>
            <category>工程实践</category>
            <enclosure url="https://home.gqy20.top/blog/260628_git_claude_hooks.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[2026 开源知识库 RAG 方案深度调研:降幻觉、技术实现与商业可用性全解析]]></title>
            <link>https://home.gqy20.top/blog/260627_rag_kb_survey</link>
            <guid isPermaLink="false">260627_rag_kb_survey</guid>
            <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[全面调研 GitHub 上 8 个主流开源知识库/RAG 方案,深入分析分块策略、Embedding 配置、文件预处理,并以「降低大模型幻觉」为主线,剖析 RAGFlow 的双流派融合与 GraphRAG 的实测效果,以及各项目的开源协议和商业可用性;并前瞻 2026 新涌现的「Agent 记忆 / 上下文图」赛道(GBrain / Mem0 / Graphiti / A-RAG)。助你选择最适合的知识库方案。]]></description>
            <author>Qingyu Ge</author>
            <category>RAG</category>
            <category>知识库</category>
            <category>Embedding</category>
            <category>降幻觉</category>
            <category>GraphRAG</category>
            <enclosure url="https://home.gqy20.top/blog/260627_rag_kb_survey.jpg" length="0" type="image/jpg"/>
        </item>
    </channel>
</rss>