本文发布于公众号:移动开发那些事 用 LangGraph 落地飞书 Bug 自动修复 Agent 实践
先看成品。配好 .env,终端敲一行:
bash
uv run python main.py
然后这个 Agent 会自己把下面这条链路跑完:
- 从飞书项目(Meego / Meegle)拉出挂在我名下、状态是「新提交」的 bug;
- 把每个 bug 的描述、复现步骤、日志附件拉下来,喂给本地
claude做根因分析; - 在一个隔离的 git worktree 里,调
claude -p直接改代码; - 把
git diff打到终端给我过目,我敲approve之后才 commit、push 修复分支; - 回到飞书项目,在这条 bug 下留一条评论,写清楚处理结果和分支名。
一条处理完,游标自动挪到下一条。中间某条挂了,也不影响这一批里剩下的。
这篇就照着我自己的用法,把这套东西从零搭一遍。
备注 :这里以我常用的Claude为例来说明,也可以换成任意的其他Agent,如Codex,Germini,Workbuddy之类的
1 背景
搭这个 自动化Agent 之前,哪些 bug 要修、怎么修,全靠我人工判断,繁琐又耗时。日常修 bug 的流程其实高度重复:
飞书项目里收到一条 bug → 点开看描述、下载日志附件 → 定位根因(这步现在也交给 AI 了) → 从主干拉一个修复分支 → 改代码 → 提交推送 → 回飞书项目评论一句「已修复,分支 xxx」。
每一步都不难,但每天重复若干遍,就是纯体力活(特别是当你在做着其他事情的时候,测试说测出一个重要bug,优先看一下时,就会特别烦躁)。而这两年真正变了的一件事是:claude 这类编码 CLI(换成 codex、glm 之类也行)已经能自己读代码、改代码了。也就是说,上面链路里最硬的「定位根因 + 改代码」这一环,可以直接外包给 AI。
那剩下没被自动化的是什么?是编排和集成:
-
谁去飞书项目把 bug 和日志捞出来?
-
十条 bug 串行处理,谁保证 A 的改动不会污染 B 的工作区?
-
push 之前谁拦一道人工审批,别让 AI 把没谱的代码直接推上去?
-
某条 bug 分析失败了,怎么让它别拖垮整批?
这些胶水和护栏,就是本文这个 Agent 要干的活。claude 负责动脑动手,Agent 负责调度和兜底。
2 为什么选 LangGraph
搭 Agent 的框架不少,先聊聊 CrewAI,再说为什么我没用它。
CrewAI 是什么。 它是一个多智能体协作框架,思路是像开公司一样组织 AI:你定义几个有角色、有目标、有人设的 Agent(比如「研究员」「作家」),给每个 Agent 分配 Task,再用一个 Crew 把它们打包,指定协作方式(顺序执行、层级管理等等)。上游 Agent 的产出会自动当成上下文喂给下游,团队自己「你来我往」把活干完。
它适合什么场景。 需要多个角色分工、又允许 Agent 自己决定怎么完成的开放式工作。典型的像「调研 → 撰稿 → 审校」的内容生产线,「产品经理 + 工程师 + 测试」的方案头脑风暴,多来源信息的聚合分析。CrewAI 的强项是让几个 AI 自由发挥、互相配合。流程越发散、越需要智能体自己想办法,它越有用。
而我这个场景恰好相反。 修 bug 不是那种需要多角色发散协作的开放任务,它是一条固定、线性、每步都要卡死的流水线。这里有三个硬需求,直接把选型从 CrewAI 拉到了 LangGraph:
-
流程是确定的、线性的:拉 bug → 提日志 → 分析 → 建 worktree → 修 → 校验 → 审批 → 推送 → 回写。我不需要 Agent 临场决定下一步干什么,我要的就是一条能预测的流水线。
-
要能中途卡住等人:push 前必须能停下来,把 diff 给人看,人点头才继续。
-
要能断点续跑:跑一半崩了、或者人还没来得及审批,重启后要能接着来。
LangGraph 是 LangChain 生态里的编排框架,刚好把这三件事都做成了一等公民。你只要搞懂 5 个概念就够了。
2.1 StateGraph(状态图)
整个 Agent 就是一张有向图:节点是「干活的函数」,边是「干完这步走哪」。把节点和边声明清楚,compile() 一下,就得到一个能跑的图。
2.2 State(共享状态)
图里所有节点共享一份状态(我用 TypedDict 定义)。每个节点读它,返回一个增量 dict 去更新它。数据在节点之间流动,靠的就是这份 State,节点彼此不直接调用。
python
class AgentState(TypedDict, total=False):
bugs: list[Bug] # fetch 后的待处理队列
cursor: int # 串行游标:现在处理到第几条
current: Optional[Bug] # 当前这条 bug
log_text: Optional[str] # 提取归一化后的日志
fix_plan: Optional[FixPlan] # 分析产出:根因 + 修复指令
worktree_path: Optional[str]# 当前 bug 的隔离工作区
branch: Optional[str] # 修复分支名
require_human_approval: bool
approved: bool
outcome: Optional[str] # fixed / no_change / rejected / error
bug_failed: bool # 当前 bug 是否已失败(下游据此短路)
error: Optional[str]
results: list[BugResult] # 累积的每条处理结果(最终汇总)
2.3 Node(节点)和 Conditional Edge(条件边)
节点就是普通 Python 函数:(state) -> dict。边分两种,普通边(A 干完固定走 B)和条件边(跑一个路由函数,按 state 决定走哪个分支)。「队列空了就收尾」「没产生改动就跳过审批」「审批被拒就不推送」,都是靠条件边实现的。
2.4 Checkpointer(检查点)
给图挂一个 SqliteSaver,它会在每个节点之后把 State 落盘。配一个 thread_id,就能中断、跨进程恢复。人工审批能「停下来等」,全靠它。
2.5 interrupt(人工中断)
在节点里调 interrupt(payload),图会当场暂停,把 payload 抛给外层,等外层 Command(resume=决定) 把答案传回来再继续。这就是我们的「push 前审批门」。
一句话说清分工:LangGraph 是编排大脑,管顺序、兜底、卡点;claude CLI 是干活的手,管分析和改码;飞书项目 CLI 和 git 是对接外部世界的两只手。
3 搭建 Agent
整个工程按「一个模块一个单一职责」拆开,目录长这样:
bash
feishu_agent/
├── main.py # 入口:auth 预检、装配、编译图、审批循环、打印汇总
├── config.py # 配置加载 + 启动期校验(fail fast)
├── common/ # 统一异常 + subprocess 封装
├── meego/ # 飞书项目 CLI 封装 + 数据模型
├── logs/ # 日志提取与归一化
├── analysis/ # 用 claude 做根因分析 → FixPlan
├── coding/ # claude -p 改码封装
├── vcs/ # git worktree 生命周期 + 分支/commit/push
├── graph/ # State / 节点 / 组装图(唯一编排层)
└── tests/ # 单元 + 集成测试
依赖边界很重要:graph/nodes.py 是唯一的编排层,它调用各领域模块;领域模块之间互不依赖,只通过 State 传数据。这样每个模块都能被单独 mock 测试。
下面分四步走。
3.1 读取飞书项目信息
这是整个工程里唯一要真刀真枪去摸的外部接口。飞书项目提供了 CLI(meegle),凭证由 CLI 自管。这里假设你已经在终端跑过 meegle auth login 登录了。官方文档在这:project.feishu.cn/b/helpcente...
我先用 meegle inspect 加只读探测,把要用到的命令和响应结构摸清楚,整理成一张契约表:
| 用途 | 命令 | 响应结构(已确认) |
|---|---|---|
| 登录预检 | meegle auth status --format json |
{"authenticated": bool, ...} |
| 名下待办 | meegle mywork todo --action todo --page-num N --format json |
{"list": [{"project_key", "work_item_info": {...}, "state_info": {...}}], "total"} |
| 工作项详情 | meegle workitem get --work-item-id <id> --project-key <pk> --fields '["_all"]' --format json |
{"work_item_attribute": {...}, "work_item_fields": [{"key","name","value"}]} |
| 下载附件 | meegle attachment +download <file_url> --project-key <pk> --work-item-id <id> --output <path> |
落盘到 --output |
| 加评论 | meegle comment add --work-item-id <id> --content <md> --project-key <pk> --format json |
--- |
摸这套接口时踩了三个坑,值得提前知道:
work_item_type_key是不透明的类型 ID(长得像66cbf16272eee03e8a2dfabd),不是字面的"bug"。想只处理缺陷,得先用meegle workitem meta-types --project-key <k>查出缺陷类型的 key,配到MEEGLE_BUG_TYPE_KEY。留空就不过滤,所有待办都处理。- 自定义字段的
key也是不透明的(field_xxxxxx),只有name(中文标签,像「缺陷描述」「多个附件」)是稳定的。所以日志字段、附件字段都按name做关键词匹配,不按 key。 - 附件字段的值是数组,元素长这样
{"name","size","type","uid","url"},其中url就是下载用的file_url。
把这些沉淀成一个薄封装 MeegoClient,对上层只暴露 5 个语义清楚的方法:
python
class MeegoClient:
"""封装 meegle CLI(假设已完成登录)。所有子命令走统一的 run_command。"""
def _run(self, *args) -> str:
return run_command([self.cli, *args], error_cls=MeegoCliError)
def check_auth(self) -> None:
out = self._run("auth", "status", "--format", "json")
if not json.loads(out or "{}").get("authenticated"):
raise MeegoCliError("meegle 未登录,请先在终端执行 `meegle auth login`")
def list_my_bugs(self, max_bugs: int) -> list[Bug]:
# 翻页拉 mywork todo,按 bug_type_key 过滤,攒够 max_bugs 或翻空为止
...
def get_bug_detail(self, bug: Bug) -> Bug:
# 拉 _all 字段,按 name 关键词抽出描述文本,收集附件数组
...
def download_attachments(self, bug: Bug, dest_dir) -> list[Path]: ...
def add_comment(self, bug: Bug, content: str) -> None: ...
注意所有子进程调用都走 common/process.py 里的一个统一封装:
python
def run_command(args, cwd=None, input_text=None, error_cls=ProcessError):
"""运行命令,返回 stdout;非零退出或二进制缺失时抛 error_cls。"""
try:
proc = subprocess.run(list(args), cwd=cwd, input=input_text,
capture_output=True, text=True)
except FileNotFoundError as exc:
raise error_cls(f"command not found: {args[0]}") from exc
if proc.returncode != 0:
raise error_cls(f"command failed ({proc.returncode}): "
f"{' '.join(map(str, args))}\n{proc.stderr}")
return proc.stdout
有一条原则我贯彻得很死:绝不静默吞错。任何外部命令(meegle / claude / git)非零退出,都抛一个带 stdout/stderr 的自定义异常。这一点在后面的容错设计里是关键。
日志提取(logs/extractor.py)也简单:把 bug.description、匹配关键词的字段、下载下来的附件内容拼到一起,归一化,超长就截断(默认 2 万字符),最后得到一段 log_text 喂给分析节点。
3.2 定下 Agent 的工作流程
这是整个 Agent 的骨架。核心是一张图,主干是「串行游标循环」:
scss
START → fetch_bugs → next_bug ──(队列空)──→ finalize → END
│
(还有 bug)
↓
extract_logs → analyze → prepare_worktree → fix → verify → approval → commit_push → report ─┐
│ │ │ │ │ │ │ │ │
└────────────┴────────────┴─────────────┴──────┴──────────┴────────────┘ (任一步异常) │
↓ │
报错短路,直达 report(记录失败 + 清理 worktree) │
│
report: 回写飞书项目评论 + 记录结果 + 清理 worktree + cursor+=1 ──→ 回 next_bug ←──────────────┘
十个节点,各管一件事:
- fetch_bugs --- 拉名下未完成 bug 队列,
cursor=0。 - next_bug (路由)---
cursor < len(bugs)就取出current进流水线,否则去finalize收尾。同时重置本条 bug 的所有临时状态(失败标记、outcome、worktree 等)。 - extract_logs --- 拉详情、下附件、归一化成
log_text。 - analyze --- 用
claude分析 bug 加日志,结构化产出FixPlan(根因 / 可疑范围 / 给 claude 的修复指令)。 - prepare_worktree --- 基于远端主干(不是本地当前分支!)建一个隔离 worktree 和修复分支。
- fix --- 在 worktree 目录里执行
claude -p <修复指令>,让它直接改文件。 - verify --- 轻量守卫:
git diff非空才算真改了;空的话标记no_change,直接去 report。 - approval --- 人工审批门(下面细讲)。
- commit_push --- commit、push 修复分支(push 前查远端有没有同名分支,防重)。
- report --- 回写飞书项目评论、记录
BugResult、清理 worktree、cursor+=1,回到 next_bug。
这套流程里有三个我特意做的设计决策。
第一,串行加 worktree 隔离。 为什么不并行?因为工作副本是单一固定的一份,多个 bug 同时 checkout / 改码 / commit 必然打架,所以硬约束就是串行。但串行不代表脏。每条 bug 都在自己的 git worktree 里干活,互不污染:
python
def prepare_worktree(state: dict) -> dict:
branch = f"{cfg.branch_prefix}{state['current'].id}"
# 基于远端主干创建修复分支,绝不基于本地当前分支
base_ref = repo.resolve_base_ref(cfg.repo_path, cfg.base_branch)
wt = worktree.create(cfg.repo_path, branch, cfg.worktree_root, base_ref)
return {"worktree_path": str(wt), "branch": branch}
第二,单 bug 失败隔离。 每个流水线节点都套一层 _guarded:这条 bug 已经失败了就短路(直接返回空 dict);节点抛异常就捕获、标记 bug_failed、记录 error,但绝不让异常冒泡中断整批。失败的 bug 会短路到 report,记一笔「error」,然后接着处理下一条。
python
def _guarded(name: str, fn: NodeFn) -> NodeFn:
"""bug_failed 时短路;异常转为失败标记,不中断整批。"""
@wraps(fn)
def wrapped(state: dict) -> dict:
if state.get("bug_failed"):
return {}
try:
return fn(state)
except Exception as exc: # 边界统一转错误标记
log.exception("node %s failed", name)
return {"bug_failed": True, "error": f"{name}: {exc}"}
return wrapped
第三,人工审批门,前期防呆,稳定后放开。 这是我最看重的护栏。前期 REQUIRE_HUMAN_APPROVAL=true:push 之前,approval 节点调 interrupt(),把 bug 摘要、根因、完整 git diff、目标分支抛出来,图暂停,等你在终端敲 approve / reject:
python
def approval(state: dict) -> dict:
if not state.get("require_human_approval"):
return {"approved": True} # 全自动模式:直接放行
decision = interrupt({
"bug_id": state["current"].id,
"title": state["current"].title,
"branch": state.get("branch"),
"root_cause": state["fix_plan"].root_cause,
"diff": repo.get_diff(state["worktree_path"]),
})
approved = str(decision).strip().lower() in ("approve", "yes", "y", "true")
return {"approved": approved, "outcome": None if approved else "rejected"}
等你把流程跑顺、信得过了,改成 false 就是全自动。
把节点和边声明清楚,build_graph 组装出整张图:
python
def build_graph(deps: Deps, checkpointer=None) -> CompiledStateGraph:
nodes = make_nodes(deps)
g = StateGraph(AgentState)
for name, fn in nodes.items():
g.add_node(name, fn)
g.add_edge(START, "fetch_bugs")
g.add_edge("fetch_bugs", "next_bug")
g.add_conditional_edges("next_bug", route_after_next,
{"extract_logs": "extract_logs", "finalize": "finalize"})
g.add_edge("extract_logs", "analyze")
g.add_edge("analyze", "prepare_worktree")
g.add_edge("prepare_worktree", "fix")
g.add_edge("fix", "verify")
g.add_conditional_edges("verify", route_after_verify,
{"approval": "approval", "report": "report"})
g.add_conditional_edges("approval", route_after_approval,
{"commit_push": "commit_push", "report": "report"})
g.add_edge("commit_push", "report")
g.add_edge("report", "next_bug") # 闭环:处理完回到 next_bug 取下一条
g.add_edge("finalize", END)
return g.compile(checkpointer=checkpointer)
「用 claude 分析」和「用 claude 改码」其实是同一招,都是拿 prompt 调 claude -p,只是模式和产物不同:
python
# analysis/analyzer.py:让 claude 在空目录里只读 bug+日志,吐一段 JSON
out = run_command([claude_cmd, "-p", prompt, "--output-format", "json"], cwd=tmp)
# → 解析成 FixPlan(root_cause, suspect_areas, fix_instruction)
# coding/claude_cli.py:让 claude 在 worktree 里直接改文件
out = run_command([claude_cmd, "-p", prompt, "--permission-mode", "acceptEdits"], cwd=worktree)
两个细节。分析时故意把 cwd 指到一个空临时目录,避免 claude 去扫当前项目上下文(分析只需要 bug 和日志)。改码时用 --permission-mode acceptEdits 让它自动落盘,注意是 acceptEdits,不是 --dangerously-skip-permissions,合规不裸奔。
3.3 用 AI 把整个 Agent 搭出来
这一节是本文跟普通教程最不一样的地方:上面那些代码,我一行行手写的很少,绝大部分是用 Claude Code 驱动着生成的。搭一个「用 AI 修 bug 的 Agent」,这过程本身就是一次「用 AI 写 Agent」的实战。方法论就三步:先想清楚,再落成计划,再逐任务执行。
第一步:Brainstorm,把需求和边界吵清楚。 别一上来就让 AI 写代码。先跟它把关键决策一个个敲定:bug 从哪来、怎么隔离、要不要人工审批、分析用什么模型。这一轮的产物是一份设计文档(docs/.../feishu-bug-fix-agent-design.md),里面有一张「关键决策表」:
| 维度 | 决策 |
|---|---|
| Bug 来源 | 飞书项目,名下未完成缺陷 |
| Git 隔离 | 每个 bug 独立 git worktree |
| 修复引擎 | shell 调用 claude -p |
| 执行方式 | 串行(单一工作副本无法并发) |
| 人工审批 | 开关 REQUIRE_HUMAN_APPROVAL,前期 true |
第二步:落成一份 TDD 计划,AI 才有施工图。 让 Claude Code 把设计拆成一份任务清单(docs/.../feishu-bug-fix-agent.md),每个模块一个 Task,每个 Task 严格按 RED → GREEN → REFACTOR 展开:先写会失败的测试、跑一遍确认失败、再写最小实现、跑一遍确认通过、最后重构。计划里连命令和预期输出都写死了:
ini
## Task 4: meegle CLI 封装
- [ ] Step 1: 写失败测试 tests/test_meego_cli.py
- [ ] Step 2: 运行确认失败 → ModuleNotFoundError
- [ ] Step 3: 写实现 meego/cli.py
- [ ] Step 4: 运行确认通过 → 7 passed
- [ ] Step 5: git commit
这一步有个关键动作叫「摸真实接口」:飞书项目 CLI 的确切命令和响应结构,是我先用 meegle inspect 加只读探测跑出来、写进计划顶部那张契约表里的。AI 不知道你内网系统长什么样,你得先把外部世界的真值喂给它,它才能生成对得上的解析代码。
第三步:逐任务执行,让 AI 自己写测试、自己验证。 有了任务清单,就让 Claude Code 一个 Task 一个 Task 地推:先写测试、跑测试看着它 RED,再写实现、跑测试看着它 GREEN,绿了就 commit,然后下一个。你的角色从「码农」变成「审阅者」,盯着每个 Task 的 diff 和测试结果,不对就打回重来。
举个例子,analyze 这个节点的 prompt 就是这么一遍遍磨出来的,目的是让本地 claude 稳定吐出能解析的结构:
python
ANALYZE_PROMPT = (
"You are a senior software engineer diagnosing a production bug. "
"Analyze the bug report and logs below, then respond with ONLY a single JSON "
"object (no markdown fences, no extra prose) with exactly these keys:\n"
'{{"root_cause": "...", "suspect_areas": ["..."], "fix_instruction": "..."}}\n'
"fix_instruction must be a concrete, self-contained instruction that a coding "
"agent can follow to patch the repository.\n\n"
"Bug title: {title}\nDescription: {description}\n\nLogs:\n{logs}\n"
)
再配一个容错解析,因为模型偶尔还是会给你包一层 json 代码块,或者多说两句:
python
def _parse_json_object(text: str) -> dict:
"""从文本里抠出最外层 JSON 对象(容忍代码块包裹和多余文字)。"""
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
raise CodingCliError(f"claude 分析输出无法解析为 JSON: {text[:200]}")
return json.loads(match.group(0))
这一节的方法论一句话概括:Brainstorm 定方向,Plan 出施工图(含真实接口契约),TDD 逐任务执行。你负责决策和把关,AI 在护栏内把活干完。
3.4 Agent 的测试
因为整套流程是测试先行搭出来的,测试覆盖天然就厚。分三层。
第一层,单元测试,外部依赖全 mock。 meegle CLI、claude CLI 都靠 mock run_command 来验证命令拼得对不对、输出解析得对不对,正常、空、异常退出码都覆盖。vcs/ 则用 tmp_path 起一个真实临时 git 仓跑真 git,验证 worktree、分支、commit、push。
第二层,集成测试,端到端跑整张图。 这是最让我有信心的一层:起一个带 bare 远端的临时 git 仓,把 meegle 和 claude 用 fake 顶替,然后真的 graph.invoke() 跑一轮,覆盖四个关键分支:
python
def test_full_auto_run_fixes_and_pushes(tmp_path, monkeypatch):
repo = _init_repo_with_remote(tmp_path)
client = _FakeClient([Bug(id="1", title="crash", project_key="pk")])
monkeypatch.setattr(nodes_mod.analyzer, "analyze", _fake_analyze)
# fake 的 claude:真的往 worktree 里写一处改动
def fake_fix(worktree, prompt, claude_cmd="claude"):
(Path(worktree) / "app.py").write_text("x = 2 # fixed\n")
return types.SimpleNamespace(stdout="edited")
monkeypatch.setattr(nodes_mod.claude_cli, "run_fix", fake_fix)
graph = build_graph(_deps(tmp_path, repo, client), checkpointer=MemorySaver())
thread = {"configurable": {"thread_id": uuid.uuid4().hex}}
state = graph.invoke({"require_human_approval": False}, thread)
assert state["results"][0].outcome == "fixed"
assert client.comments # 确实回写了评论
四个分支分别是:成功修复并推送、claude 没产生改动(no_change)、某节点抛异常但被隔离(error)、人工审批 reject 时不推送。最后一个用 Command(resume="reject") 模拟人的决定,验证被拒的 bug 不会 push。
第三层,图逻辑测试。 单独验证路由函数:next_bug 的串行游标推进、bug_failed 的短路、require_human_approval 的分支走向。
跑测试:
bash
.venv/bin/pytest
真机联调有个正确姿势:第一次跑真的飞书项目和真的 claude 时,一定把 REQUIRE_HUMAN_APPROVAL 设成 true。每条 bug 在 push 前都会把完整 diff 打给你看,你逐条 approve / reject,亲眼确认 AI 改得靠谱、分支基于对的主干、评论回写正常。等连续跑顺几批、心里有底了,再改 false 放手全自动。
4 总结
我用LangGraph 把「飞书项目取 bug → claude 分析 → worktree 隔离修复 → 人工审批 → push → 回写评论」串成了一条流水线,可预测、可断点续跑、可人工卡点。回头看,有几条设计原则是这个 Agent 稳的关键:
- 串行加 worktree 隔离:单副本的硬约束下,用 worktree 换来每条 bug 干净的工作区。
- 单 bug 失败隔离 :
_guarded把每个节点的异常收敛成失败标记,一条烂 bug 拖不垮整批。 - 人工审批门,前期防呆、稳定放开 :
interrupt()加SqliteSaver,让「停下来等人」变成一等公民。 - 分析和修复都用本地
claude:不接 OpenAI,少一个 Key、少一份账单、少一处数据出境。 - 配置 fail fast :
config.py启动就校验 REPO_PATH 是 git 仓、CLI 可执行,缺啥立刻退出,绝不带病进图。 - 绝不静默吞错:所有子进程非零退出都抛带上下文的自定义异常。
这篇文章只是给大家提供一个思路,理论上其他的项目管理工具也是有类似的cli或MCP的调用方式可供AI去做自动化的处理,大家可找自己的项目管理工具的接口把这套流程应用到自己的项目中去。