Skip to content
传衡博客
返回

【一】Debug Agent 的设计与踩坑

参考资料
  1. ReAct: Synergizing Reasoning and Acting in Language Models
  2. SWE-bench: Can Language Models Resolve Real-World GitHub Issues?
  3. TraceForge — GitHub

让 LLM 用 PDB 调试 Python bug,在 SWE-bench 100 个真实 GitHub issue 上拿到 65% 的解决率。这篇文章记录 Debug Agent 的完整设计:为什么只给 3 个 PDB 工具、ReAct 循环里怎么防止 LLM 编造 Observation、subagent 怎么和主 Agent 隔离,以及 6 个从真实评测中踩出来的坑。

整体架构

系统分三层。Agent 层负责 ReAct 决策循环,Model 层封装 LLM API 和 tool call 解析,Environment 层在 Docker 容器里执行 bash 命令和 PDB 会话。

主 Agent(main_debug)负责全局编排:读代码、定位可疑区域、调用 debug_subagent 做运行时诊断、根据诊断结果生成 patch。Debug SubAgent 是一个隔离的 ReAct 实例,有自己的消息历史、步数预算(25 步)和工具集(10 个),用完即销毁。

SWE-bench Instance

  MainDebugAgent
      ├── bash(静态分析:grep、read_file)
      ├── debug_subagent(question, test)
      │       └── DebugAgent(隔离 25 步 ReAct)
      │             ├── 3 个 PDB 工具
      │             ├── 5 个文件工具(read_file, grep, list_dir, ...)
      │             └── finish(返回诊断结论)
      └── patch(生成 + 验证修复)

主 Agent 有一个编辑门控: 在第一次调用 debug_subagent 之前,所有写操作(patch、sed -i、rm、git apply)都会被拦截。这强制 Agent 先做诊断、再做修改,避免跳过调试直接猜 patch。

为什么只给 3 个 PDB 工具

PDB 本身有几十个命令,但我只暴露了 3 个工具给 LLM:

工具参数内部映射作用
run_to(location?)可选 file:lineb {loc} + c + w + l运行到指定位置或崩溃点
execute(expression)Python 表达式pp {expr}!stmt检查变量或修改状态
navigate_stack(direction)up / downu / d + l + w在调用栈上下移动

第一个设计决策是不让 LLM 发任意 PDB 命令。 理由有三:安全(whitelist 比 blacklist 可靠)、效率(每个工具自动附带 where + list 输出,省掉 Agent 两步)、可靠性(避免 LLM 发出无意义的命令序列)。

run_to 自动附带上下文。 一次 run_to 调用 = 设断点 + continue + 打印调用栈 + 打印当前代码。如果让 Agent 自己组合这四步,每次定位至少要 4 个 tool call,25 步预算很快耗尽。

execute 支持写操作。! 前缀可以在 PDB 里修改变量(比如 !doc = None),这让 Agent 能做假设验证:改一个变量看行为是否变化。这比只读检查的信息量大得多。

navigate_stack 而不是 step/next 我删掉了单步执行(n/s),只保留栈导航。原因是 LLM 在长序列单步执行时极容易迷失位置,而”在调用栈上移动 → 检查不同层的变量”是诊断 bug 最高效的模式。

ReAct 循环的工程细节

标准 ReAct [1] 是 Thought → Action → Observation 循环。实际工程中要处理很多论文没提到的问题。

消息格式:多轮对话而不是单条长文本

早期版本把所有 Thought/Action/Observation 拼成一条长文本塞进 user 消息。后来发现 LLM 会把自己生成的 Observation 编造出来:它在 Action: 之后预测最可能的下一个 token,结果就是一个假的 Observation:,而不是等待真实的工具执行结果。

改成多轮对话格式后,每个 tool call 的结果作为独立的 user 消息返回,LLM 看到清晰的角色边界(assistant = 自己的输出,user = 系统反馈),编造率显著下降。

Turn 1: [system, user(question)]                              → assistant(thought + action)
Turn 2: [system, user(question), assistant(...), user(obs1)]  → assistant(thought + action)
Turn 3: [system, user(question), ..., user(obs2)]             → assistant(thought + action)

最终解决方案是用原生 function calling。 GPT-4、Claude 等模型的 API 保证生成在 tool call 边界停止,从根本上消除了 Observation 编造。

重复检测与策略切换

Agent 有时候会陷入循环:连续 3 步做同样的事(比如反复 run_to 同一个断点)。系统用 SHA1 哈希追踪每步的 action + 参数 + 输出:

连续 2 步相同 → 注入提示:“你已经看到相同的结果了,换一个调查方向。”

连续 3 步相同 → 注入强制收尾提示:“用现有证据调用 finish。“

步数预算管理

25 步是 SubAgent 的硬上限。步数管理分三个阶段:

步 1-23:自由探索,不干预。

步 24:注入收敛提示,告诉 Agent 停止开新分支、准备总结。

步 25:注入强制结束提示,这是最后一步,必须调用 finish。

如果 25 步用完还没调用 finish,系统会额外注入一个总结 prompt,让 LLM 用已有的证据产出一个最终答案。

SubAgent 隔离架构

Debug SubAgent 的核心设计是完全隔离:独立的消息历史、独立的步数预算、独立的 model 实例。用完即销毁。

def _run_debug_subagent(self, question, test):
    debug_agent = DebugAgent(
        model=create_debug_model(config),
        env=self.env,
        step_limit=25,
    )
    findings = debug_agent.run(question, test)
    # 保存轨迹到磁盘
    save_artifacts(question, test, findings, debug_agent.trajectory)
    return findings

为什么隔离? 主 Agent 的上下文可能已经有 50K+ token(读过很多文件、做过静态分析)。如果把 PDB 交互也混进去,上下文会爆炸,而且调试和修复是两个不同的任务,prompt 调优互相干扰。

输入只有两个:question 和 test。 question 是主 Agent 对 bug 的描述(比如”检查 _get_fields() 在空 model 上是否返回空列表”),test 是一个可运行的失败测试。SubAgent 不知道主 Agent 读过什么文件、做过什么分析。

输出是自然语言诊断。 SubAgent 不直接产出 patch,只返回”我发现了什么”。修复的决策权留给主 Agent。

轨迹存档

每次 SubAgent 调用都完整保存:question、test、诊断结论、全量轨迹 JSON。这些轨迹就是训练偏好模型的数据来源(详见第五篇:偏好训练)。

case_dir/subagents/
├── 001/
│   ├── question.txt
│   ├── test.txt
│   ├── debug_answer.txt
│   ├── meta.json
│   └── trajectory.traj.json
├── 002/
│   └── ...

System Prompt 设计

Debug SubAgent 的 system prompt 有几个关键约束:

角色定位是”运行时预言机”。 不是修复者,是调查者。prompt 明确说”你的工作是 INVESTIGATE,不是 FIX”,防止 Agent 跳过诊断直接猜修复。

优先运行时证据。 “遇到不确定的问题,先用 PDB 实际跑一下,不要只靠 grep 猜。“这一条显著减少了 Agent 在静态搜索上浪费步数的情况。

输出格式强制。 诊断结论必须包含 Question/Answer/Evidence/Locations 四个字段。Evidence 要求引用具体的变量值和堆栈位置,不能只说”可能是这个问题”。

6 个真实评测踩出来的坑

坑 1:Django 多进程与 PDB 不兼容

Django 的 runtests.py 默认用多进程并行跑测试。PDB 只能附加到主进程,worker 进程里的断点直接 BdbQuit

修复: 检测到 runtests.py 时自动追加 --parallel 1

坑 2:pytest 抢占 stdin

pytest 默认捕获 stdin/stdout。PDB 需要交互式 stdin,冲突后报 OSError: reading from stdin while output is captured

修复: 检测到 pytest 时自动追加 -s(关闭 capture)。

坑 3:过度校验反而破坏功能

为了防止 LLM 发送非法断点,我加了正则校验。结果这个正则比 PDB 本身还严格,把条件断点(file:line, condition)也拦掉了。

教训: 不要替 LLM 做防御。让 PDB 自己报错,LLM 看到错误信息后会自我修正。

坑 4:PDB 会话无断点自动退出循环

PdbSession.start() 无条件发送 c(continue)。如果没设断点,测试跑完 → PDB 退出 → Agent 重启 → 重复 15 次。

修复: 无断点时 PDB 停在第一行,等 Agent 自己决定设断点。

坑 5:主 Agent 泄露金标信息

主 Agent 的 question 里提到了测试的具体细节(来自 gold patch 的测试名),相当于告诉 SubAgent 答案。在闭卷评测里这是作弊。

修复: question 只描述 bug 症状(“某函数在空输入下返回错误结果”),不泄露实现细节。

坑 6:Observation 编造

用 GLM-4.7 测试时,75% 的情况下 LLM 会在 Action 后面自己编一个 Observation,而不是等待真实执行结果。

修复: 多轮对话格式 + 原生 function calling。详见上文”消息格式”段落。

效果

评测指标结果
QuixBugs(40 个算法 bug)诊断成功率82.5%(33/40)
QuixBugs修复成功率60.0%(24/40)
SWE-bench 100 实例解决率(baseline)65%
SWE-bench 100 实例平均 subagent 步数(简单 bug)3-7 步
SWE-bench 100 实例平均 subagent 步数(复杂 bug)12-23 步

Debug SubAgent 对复杂语义 bug 帮助最大。 需要追踪多个函数调用链的 bug,PDB 的运行时证据比静态 grep 高效得多。但对简单类型错误(比如参数名写错),反而不如直接猜 patch 快。

小结

3 个 PDB 工具 + 隔离 SubAgent + 25 步预算,足以让 LLM 在真实 GitHub issue 上做有效的运行时调试。设计的核心取舍是:不追求工具的灵活性(只给 3 个),换取每次调用的信息密度(自动附带上下文)和安全性(whitelist)。最大的教训是不要替 LLM 做过多防御,让工具本身报错比在 Agent 层加校验更有效。训练数据采集方面,每次 SubAgent 调用的完整轨迹都会存档,这些轨迹就是后续第五篇:偏好训练的数据来源。



Previous Post
【二】SWE-bench 评测与 Docker 测评环境构建
Next Post
【零】TraceForge 系列专题:用 PDB 给 Agent 装上调试器