WeClaw_67_Pre-completion_Checklist:AI的交付前自检清单
第四季系列文章第 6 篇(总第 67 篇) - Completion Checklist · 交付前自检 · 双路径差异 · 流式后置告知 · 非流式拦截重入
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 第四季
专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用
本文深入探讨 Harness Engineering 七大差距中的 G5——Pre-completion Checklist。Agent 在完成多步任务后,可能遗漏验证步骤——文件已生成但未验证、工具报错未处理、用户请求只完成了一半。本文设计 CompletionChecklist:一个纯规则的交付前自检清单,在 Agent 输出最终回复前拦截检查。重点讨论非流式和流式路径的差异化策略——非流式可以安全拦截重入循环,流式文本已 yield 给用户只能后置告知——以及这一差异如何影响了 Reasoning Sandwich 的设计决策。
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 💻 项目地址:https://github.com/wyg5208/weclaw.git
- 🌐 官网地址:https://weclaw.link
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
- ⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览: 从 Agent 的"半成品交付"现象出发——用户要求"修改配置文件并验证",Agent 修改了文件但忘记验证就直接返回"完成了"。然后设计 CompletionChecklist:在 Agent 输出最终回复前插入一道自检关卡,检查四类常见遗漏:文件已生成但未验证、工具报错未处理、用户请求部分完成、多步操作缺少摘要。重点讲解非流式路径的"拦截重入"策略和流式路径的"后置告知"策略——这一差异是 V3 评审中最重要的代码级发现之一。
核心问题: Agent 在多步 ReAct 循环后,何时算"真正完成"?它修改了文件、执行了测试、但测试报错了——Agent 选择忽略报错直接返回"已完成"。如何在交付前强制自检?
关键成果:
- 设计了四条自检规则,纯规则检查零 token 成本
- 非流式路径:拦截重入循环,最多额外 1 步
- 流式路径:后置告知模式,追加提示但不重入
适合读者:AI Agent 开发者、对 LLM 输出质量控制感兴趣的工程师
阅读时长:约 15 分钟
关键词:Completion Checklist、交付前自检、拦截重入、流式后置告知、双路径差异、零 token 成本
一、Agent 的"半成品交付"现象
1.1 典型场景
用户:"帮我修改 config.toml 中的端口配置,然后验证修改是否生效。"
Agent 的执行序列:
Step 1: file.read("config.toml") ✅ 读取配置
Step 2: file.write("config.toml", ...) ✅ 修改端口
Step 3: shell.execute("cat config.toml") ✅ 查看修改结果
Step 4: 返回"已完成修改" ⚠️ 但用户要求"验证是否生效"
Agent 跳过了"验证是否生效"这一步——它把"查看修改结果"等同于"验证是否生效"。这是一种常见的完成度幻觉:Agent 认为自己做完了,但实际上遗漏了关键步骤。
1.2 四类常见遗漏
| 遗漏类型 | 描述 | 例子 |
|---|---|---|
| 文件生成但未验证 | 调用了 file.write 但没有验证文件内容 | 写了文件但没 cat 检查 |
| 工具报错未处理 | 某步工具返回错误但 Agent 继续执行 | shell 返回非零退出码但被忽略 |
| 请求部分完成 | 用户要求 A+B 但只做了 A | "修改并验证"只做了修改 |
| 多步无摘要 | 执行了 10 步但没有总结 | 直接返回"done" |
1.3 为什么 Agent 会"半成品交付"?
ReAct 循环:
Thought → Action → Observation → Thought → Action → ... → Final Answer
↑ ↑
│ │
开始执行 "我认为做完了"
当 Agent 进入"Final Answer"阶段时,它的思维模式从"执行"切换到"总结"。这个切换可能导致它忽略之前的未完成步骤——就像你在赶火车时匆忙收拾行李,到了车站才发现忘带了充电器。
Pre-completion Checklist 的本质是:在"我认为做完了"和"真正告诉用户做完了"之间,插入一道强制检查关卡。
二、CompletionChecklist 设计
2.1 核心数据结构
@dataclass
class CheckItem:
"""一条自检结果。"""
name: str # 检查项名称
passed: bool # 是否通过
severity: str # "MUST_FIX" | "SHOULD_FIX"
message: str # 告警消息
class CompletionChecklist:
"""任务完成前强制自检清单。纯规则检查(零 token 成本)。"""
DEFAULT_CHECKS = [
"file_generated_but_not_verified",
"tool_error_not_addressed",
"user_request_partially_fulfilled",
"no_summary_after_multi_step",
]
def check(self, trace, messages, user_input) -> list[CheckItem]:
"""执行所有自检规则。"""
...
def should_intercept(self, check_results) -> bool:
"""判断是否需要拦截(有任何 MUST_FIX 未通过)。"""
return any(
not item.passed and item.severity == "MUST_FIX"
for item in check_results
)
def get_checklist_prompt(self, failed_checks) -> str:
"""生成给 Agent 的补充提示。"""
...
def get_notice_suffix(self, failed_checks) -> str:
"""生成给用户的后置告知文本。"""
...
2.2 四条自检规则
def _check_file_verified(self, trace, messages) -> CheckItem:
"""规则1:文件已生成但未验证。"""
# 检查 trace 中的工具调用
writes = [tc for tc in trace.tool_calls if tc.action in ("write", "create")]
reads_after_write = [
tc for tc in trace.tool_calls
if tc.action in ("read", "cat") and tc.step > writes[-1].step
] if writes else []
passed = len(writes) == 0 or len(reads_after_write) > 0
return CheckItem(
name="file_generated_but_not_verified",
passed=passed,
severity="SHOULD_FIX",
message="文件已修改但未验证内容" if not passed else "",
)
def _check_tool_errors(self, trace, messages) -> CheckItem:
"""规则2:工具报错未处理。"""
errors = [tc for tc in trace.tool_calls if tc.result_status == "error"]
# 检查报错后是否有重试或错误处理
unhandled = []
for err in errors:
has_retry = any(
tc.step > err.step and tc.tool_name == err.tool_name
for tc in trace.tool_calls
)
if not has_retry:
unhandled.append(err)
return CheckItem(
name="tool_error_not_addressed",
passed=len(unhandled) == 0,
severity="MUST_FIX" if unhandled else "SHOULD_FIX",
message=f"{len(unhandled)} 个工具错误未处理" if unhandled else "",
)
def _check_request_completeness(self, trace, user_input) -> CheckItem:
"""规则3:用户请求是否完整完成。"""
# 简单规则:如果用户输入包含"并"或"然后",检查是否两部分都执行了
if "并" in user_input or "然后" in user_input:
parts = user_input.replace("然后", "并").split("并")
if len(parts) >= 2:
# 检查是否两部分意图都有对应工具调用
# 这是一个启发式检查,不追求100%准确
passed = trace.total_steps >= len(parts) * 2 # 每部分至少 2 步
return CheckItem(
name="user_request_partially_fulfilled",
passed=passed,
severity="MUST_FIX",
message="复合请求可能只完成了一部分" if not passed else "",
)
return CheckItem(name="user_request_partially_fulfilled",
passed=True, severity="SHOULD_FIX", message="")
def _check_summary(self, trace, messages) -> CheckItem:
"""规则4:多步操作后是否有摘要。"""
if trace.total_steps >= 5:
last_message = messages[-1].get("content", "") if messages else ""
# 检查最后一条消息是否包含总结性内容
has_summary = any(
kw in last_message for kw in ["完成", "已", "结果", "总结", "修改"]
)
return CheckItem(
name="no_summary_after_multi_step",
passed=has_summary,
severity="SHOULD_FIX",
message="多步操作后缺少结果摘要" if not has_summary else "",
)
return CheckItem(name="no_summary_after_multi_step",
passed=True, severity="SHOULD_FIX", message="")
三、双路径差异化策略 —— V3 最重要的代码级发现
3.1 问题:流式文本已经"泼出去"了
这是 V3 评审中最关键的发现之一。非流式和流式路径对 Checklist 的处理必须不同:
非流式路径(_chat_impl):
模型生成回复 → 检查 Checklist → 如果未通过 → 重入循环
↓
文本尚未发给用户 ✅ 可以安全拦截
流式路径(chat_stream):
模型生成 delta → yield delta → 用户看到了 → ... → 生成完毕
↓
检查 Checklist
↓
如果未通过 → ❌ 文本已经 yield 给用户了!
无法回收!
3.2 V1 方案的错误:流式路径也用 continue
V1 方案假设两条路径都可以用 continue 重入循环。但代码级评审发现:流式路径的文本已经通过 yield delta_content 实时发送给用户了。如果此时 continue 回到循环开头,用户会看到:
(前面的回复文本已经显示)
(循环重入后的新文本又开始显示)
→ 用户看到两段文本拼接在一起,非常混乱
3.3 V3 修复:流式路径改为后置告知
# === 非流式路径(_chat_impl):continue 模式 ===
# 注入点:L1652,response.content = content 之后、add_assistant_message 之前
if self._completion_checklist and step_idx < self.max_steps - 1:
checks = self._completion_checklist.check(
trace_collector, self.session_manager.get_messages(), user_input
)
if self._completion_checklist.should_intercept(checks):
# 文本尚未发送,可以安全拦截
check_prompt = self._completion_checklist.get_checklist_prompt(checks)
self.session_manager.add_message("user", check_prompt)
continue # 回到 ReAct 循环,最多额外 1 步
# === 流式路径(chat_stream):后置告知模式 ===
# 注入点:L2413,add_assistant_message 之前
if self._completion_checklist:
checks = self._completion_checklist.check(
trace_collector,
self.session_manager.get_messages(session_id=session_id),
user_input
)
if self._completion_checklist.should_intercept(checks):
# 文本已 yield 给用户,无法回收
# 只能追加告知提示
notice = self._completion_checklist.get_notice_suffix(checks)
yield f"\n\n---\n{notice}"
collected_content += f"\n\n---\n{notice}"
logger.info("Checklist 后置告知: %s", [c.name for c in checks if not c.passed])
3.4 后置告知的用户体验
用户在流式模式下看到的:
我已经修改了 config.toml 中的端口配置,将默认端口从 8080 改为 3000。
---
⚠️ 系统自检提示:检测到 1 项可能未完成的操作
- 工具报错未处理:shell 命令返回非零退出码,请检查配置是否生效
这个体验不如非流式路径的"静默重入"——但它是流式架构下的最佳选择。流式的本质是"实时"——你无法把已经说出去的话收回来。
四、配置与跳过策略
4.1 配置
[agent.completion_checklist]
enabled = false
max_extra_steps = 1 # 非流式路径最多额外重入 1 步
skip_intents = ["casual_chat", "greeting"] # 跳过的意图
4.2 意图跳过
不是所有任务都需要自检。闲聊和问候类意图可以直接跳过:
def should_check(self, intent: str) -> bool:
"""判断当前意图是否需要自检。"""
if intent in self._skip_intents:
return False
return True
4.3 步数限制
max_extra_steps = 1 确保非流式路径最多额外重入 1 步——即使自检发现问题,Agent 也只有 1 次修正机会。这防止了"自检→修正→自检→修正"的无限循环。
五、与 Reasoning Sandwich 的关联
5.1 原计划的联合设计
V2 方案中,Reasoning Sandwich(G7)计划在 Checklist 拦截后的额外步骤中切换到 reasoning 模型(如 DeepSeek-Reasoner),让模型用更强的推理能力来修复遗漏。
5.2 代码级评审发现的矛盾
V3 评审发现了一个根本性矛盾:
Reasoning Sandwich 需要:
1. 在"最终回复"阶段切换到 reasoning 模型
2. 但"最终回复"阶段意味着 has_tool_calls = False
3. 而 has_tool_calls 是模型调用后才知道的
4. → 无法在模型调用前切换模型
此外,reasoning 模型(如 DeepSeek-Reasoner)不支持 function calling——它只能生成纯文本,不能调用工具。这意味着它无法在"需要工具调用"的步骤中使用。
5.3 V3 决策:降级为实验性骨架
因此,Reasoning Sandwich 在 V3 中被降级为实验性骨架——只实现模块和配置,不注入 agent.py 主循环。待 CompletionChecklist 稳定运行后,再联合启用。
未来的联合启用条件:
- CompletionChecklist 已稳定运行 2 周无误报
- 在 Checklist continue 分支内添加模型切换逻辑
- 调用时不传 tools 参数(确保 reasoning 模型不需要 function calling)
六、max_extra_steps=1 的工程权衡
6.1 为什么只允许 1 步?
| 允许多步 | 允许 1 步 | 不允许重入 |
|---|---|---|
| 可能无限循环 | 最多多 1 步 | 自检发现问题但无法修正 |
| Token 成本不可控 | 成本可控 | 用户体验差 |
max_extra_steps=1 是一个折中:给 Agent 1 次修正机会,但不允许无限修正。
6.2 1 步够用吗?
大部分遗漏可以在 1 步内修正:
- "文件未验证" → 1 步 file.read 即可
- "工具报错未处理" → 1 步重试或报错说明即可
- "请求部分完成" → 1 步补充说明即可
只有极少数复杂遗漏需要多步修正——这些场景可以通过"后置告知"让用户决定是否继续。
七、Checklist 的测试策略
7.1 测试场景
def test_file_not_verified():
"""场景1:文件已写但未验证 → 应拦截"""
trace = MockTrace(tool_calls=[
MockCall(tool="file", action="write", step=1),
])
checklist = CompletionChecklist()
result = checklist.check(trace, [], "修改配置文件")
assert not result[0].passed # file_generated_but_not_verified
def test_file_verified():
"""场景2:文件已写且已验证 → 应通过"""
trace = MockTrace(tool_calls=[
MockCall(tool="file", action="write", step=1),
MockCall(tool="file", action="read", step=2),
])
checklist = CompletionChecklist()
result = checklist.check(trace, [], "修改配置文件")
assert result[0].passed
def test_casual_chat_skip():
"""场景3:闲聊意图应跳过"""
checklist = CompletionChecklist(skip_intents=["casual_chat"])
assert not checklist.should_check("casual_chat")
assert checklist.should_check("code_modify")
7.2 误报率控制
Checklist 的最大风险是误报——不该拦截的时候拦截了,导致 Agent 多做 1 步无用功。我们通过以下方式控制误报:
- SHOULD_FIX 不拦截:只有 MUST_FIX 才触发
should_intercept - 意图跳过:闲聊、问候等意图直接跳过
- 步数门槛:少于 3 步的任务不需要自检(太简单了)
八、核心教训
8.1 流式架构的"不可逆性"
这是 V3 评审最重要的发现:流式输出是不可逆的。 一旦文本通过 yield 发送给用户,就无法回收。这意味着所有在"最终回复前"的拦截逻辑,在流式路径上都无法工作。
这个发现不仅影响了 Checklist 的设计,还直接导致了 Reasoning Sandwich 的降级——因为 reasoning 模型的切换需要"在最终回复前"决定,而流式路径无法做到。
8.2 纯规则检查的价值
CompletionChecklist 没有使用 LLM——全部是 Python 规则。这确保了:
- 零 token 成本:不调用任何模型
- 零延迟:规则检查在毫秒级完成
- 零不确定性:规则是确定性的,不会"误判"
用 LLM 做 Checklist 是可行的(更智能),但成本高且不确定。对于"交付前自检"这种高频检查,纯规则是更好的选择。
8.3 最好的拦截点是最早的拦截点
在非流式路径中,Checklist 的注入点在 add_assistant_message 之前——这意味着 Agent 还没"正式提交"它的回复。此时拦截,用户完全无感知。一旦 add_assistant_message 执行后,回复就"正式"了——再拦截就需要删除已存储的消息,复杂度大增。
📖 相关文章
本文是 WeClaw 专栏第四季的第 6 篇(总第 67 篇)。如果这篇文章对你有帮助,欢迎给项目点个 Star ⭐