返回博客列表
最佳实践2026-06-2624 分钟阅读

Pre-completion_Checklist:AI的交付前自检清单

Completion Checklist · 交付前自检 · 双路径差异 · 流式后置告知 · 非流式拦截重入

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 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 从 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 稳定运行后,再联合启用。

未来的联合启用条件:

  1. CompletionChecklist 已稳定运行 2 周无误报
  2. 在 Checklist continue 分支内添加模型切换逻辑
  3. 调用时不传 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 步无用功。我们通过以下方式控制误报:

  1. SHOULD_FIX 不拦截:只有 MUST_FIX 才触发 should_intercept
  2. 意图跳过:闲聊、问候等意图直接跳过
  3. 步数门槛:少于 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 ⭐