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

确定性约束引擎:从Prompt引导到代码级强制的范式转变

Constraint Engine · 确定性校验 · 语义级约束 · Hook 注入 · 零 token 成本

WeClaw_63_确定性约束引擎:从Prompt引导到代码级强制的范式转变

第四季系列文章第 2 篇(总第 63 篇) - Constraint Engine · 确定性校验 · 语义级约束 · Hook 注入 · 零 token 成本


📚 专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 第四季

专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用

本文深入探讨 Harness Engineering 七大差距中的 G1——架构约束自动执行。我们将从"为什么 Prompt 引导不够用"出发,设计一个确定性约束引擎(ConstraintEngine),在工具执行前进行语义级校验,并将这一过程以零 token 成本嵌入 ReAct 循环。


👨‍💻 作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 从 Agent 执行中的"约束失灵"现象出发——模型明明在 System Prompt 中被告知"不要直接调用底层 shell 执行危险命令",却仍然反复违反——分析 Prompt 引导的局限性,然后设计 ConstraintEngine:一个在工具执行前进行语义级确定性校验的规则引擎。重点讲解如何通过 Hook 注入嵌入 ReAct 循环、如何与现有的 ToolCallValidator 和 _validate_tool_relevance 协同工作,以及如何在前置基础设施修复(M0)的基础上实现零 token 成本的约束执行。

核心问题: 当你告诉 LLM "请在 file 工具中指定合法路径",它有时候听,有时候不听。Prompt 是"建议",不是"约束"。如何将关键约束从"建议"升级为"强制"?

关键成果

  • 设计了 ConstraintEngine 的核心数据结构和规则集
  • 明确了 ToolCallValidator → _validate_tool_relevance → ConstraintEngine 的三层校验链
  • 通过 Hook 注入实现双路径对称覆盖,零 token 成本

适合读者:AI Agent 开发者、对 LLM 约束控制感兴趣的工程师

阅读时长:约 15 分钟

关键词ConstraintEngine确定性约束Hook 注入ReAct 循环语义校验零 token 成本


一、问题现场 —— Prompt 引导的失灵时刻

1.1 一个典型的"约束失灵"场景

用户要求 Agent 修改一个配置文件。Agent 的执行序列如下:

Step 1: file.read("config/default.toml")      ✅ 合理
Step 2: file.write("config/default.toml", ...) ✅ 合理
Step 3: file.write("config/default.toml", ...) ⚠️ 重复编辑同一文件
Step 4: file.write("config/default.toml", ...) ⚠️ 又一次
Step 5: file.write("config/default.toml", ...) ❌ 第5次了!
Step 6: shell.execute("rm temp/*")             ❌ 危险命令!

在 System Prompt 中,我们明确告诉了模型:"不要重复编辑同一文件超过 3 次"、"shell 工具禁止执行 rm 命令"。但模型还是做了。

1.2 为什么 Prompt 引导不够?

Prompt 引导有三个根本性局限:

局限说明类比
概率性遵守LLM 会"理解"约束,但不保证100%执行告诉孩子"不要碰热 stove",但孩子有时还是会碰
上下文稀释长对话中,早期约束被后续上下文"淹没"第50条消息时,第1条的约束已经"模糊"
不可审计约束被违反时没有拦截记录出了事才知道,没有提前预警

核心洞察:Prompt 是给模型看的"说明书",不是给系统执行的"交通规则"。说明书可以被忽略,交通规则不行。

1.3 现有校验器的不足

WeClaw 已有两层校验,但它们都不够:

工具调用请求
    │
    ▼
ToolCallValidator          ← 只检查数量(max_per_call=3)
    │ PASS
    ▼
_validate_tool_relevance   ← 只检查工具与意图的相关性
    │ PASS
    ▼
执行工具                   ← ⚠️ 这里有语义级空白!

ToolCallValidator 只管"调了多少个",_validate_tool_relevance 只管"这个工具跟意图有没有关系"。但参数是否安全、依赖层级是否正确、是否重复编辑同一文件——这些语义级约束无人检查。


二、设计 ConstraintEngine —— 确定性约束规则引擎

2.1 核心数据结构

@dataclass
class ConstraintRule:
    """一条确定性约束规则。"""
    name: str                           # 规则名称
    severity: str                       # "REJECT" | "WARN"
    check_fn: Callable[[ToolCallContext], bool]  # 校验函数
    message_template: str               # 告警/拒绝消息模板

@dataclass
class ToolCallContext:
    """工具调用的完整上下文,供规则函数使用。"""
    tool_name: str
    action_name: str
    arguments: dict
    current_intent: str
    recent_calls: list[tuple[str, str, str]]  # 最近N次调用记录
    session_step: int                          # 当前步骤序号

@dataclass
class ConstraintResult:
    """约束校验结果。"""
    status: str    # "PASS" | "REJECT" | "WARN"
    message: str   # 告警/拒绝原因
    rule_name: str # 触发的规则名

2.2 规则引擎核心

class ConstraintEngine:
    """确定性约束规则引擎。在工具执行前由 ReAct 循环直接调用。"""
    
    def __init__(self):
        self._rules: list[ConstraintRule] = []
    
    def validate(self, ctx: ToolCallContext) -> ConstraintResult:
        """逐条规则校验,返回第一个 REJECT 或最后一个 WARN。"""
        last_warn = None
        for rule in self._rules:
            if rule.check_fn(ctx):
                if rule.severity == "REJECT":
                    return ConstraintResult(
                        status="REJECT",
                        message=rule.message_template.format(**ctx.__dict__),
                        rule_name=rule.name,
                    )
                elif rule.severity == "WARN" and not last_warn:
                    last_warn = ConstraintResult(
                        status="WARN",
                        message=rule.message_template.format(**ctx.__dict__),
                        rule_name=rule.name,
                    )
        if last_warn:
            return last_warn
        return ConstraintResult(status="PASS", message="", rule_name="")

设计要点

  • REJECT 优先:遇到第一个 REJECT 立即返回,不继续检查
  • WARN 不阻断:WARN 只记录告警,不阻止执行
  • 零 token 成本:全部是 Python 规则函数,不调用 LLM

2.3 初始规则集

四条规则覆盖最常见的约束失灵场景:

def _build_default_rules() -> list[ConstraintRule]:
    return [
        # 规则1:依赖层级检查
        # 禁止低层工具直接调用高层 API(如 shell 直接调用模型 API)
        ConstraintRule(
            name="dependency_layer_check",
            severity="REJECT",
            check_fn=lambda ctx: (
                ctx.tool_name == "shell" and 
                any(kw in str(ctx.arguments.get("command", "")) 
                    for kw in ["model_registry", "agent_pool"])
            ),
            message_template="禁止 shell 工具直接操作内部组件: {tool_name}",
        ),
        
        # 规则2:输出格式检查
        # 文件生成类工具必须包含合法路径参数
        ConstraintRule(
            name="output_format_check",
            severity="REJECT",
            check_fn=lambda ctx: (
                ctx.action_name in ("write", "create", "save") and
                not ctx.arguments.get("path", "")
            ),
            message_template="文件操作缺少合法 path 参数: {action_name}",
        ),
        
        # 规则3:参数安全检查
        # shell 工具禁止危险命令模式
        ConstraintRule(
            name="parameter_safety_check",
            severity="REJECT",
            check_fn=lambda ctx: (
                ctx.tool_name == "shell" and
                _has_dangerous_pattern(ctx.arguments.get("command", ""))
            ),
            message_template="shell 命令包含危险模式: {tool_name}",
        ),
        
        # 规则4:单任务文件编辑次数上限
        ConstraintRule(
            name="max_file_edits_per_task",
            severity="WARN",
            check_fn=lambda ctx: (
                ctx.action_name in ("write", "edit") and
                sum(1 for c in ctx.recent_calls 
                    if c[0] == "file" and c[1] in ("write", "edit")
                ) >= 20
            ),
            message_template="单任务文件编辑已达上限({session_step}步),请考虑合并操作",
        ),
    ]

为什么 max_file_edits 是 WARN 而不是 REJECT? 因为重复编辑可能是合理的(比如增量修改一个大文件),我们只想提醒模型注意,而不是强制阻断。


三、三层校验链 —— 与现有系统的协同

3.1 校验链全景

工具调用请求
    │
    ▼
① ToolCallValidator          ← 数量限制(max_per_call=3)
    │ PASS
    ▼
② _validate_tool_relevance   ← 意图相关性(file工具 vs casual_chat意图)
    │ PASS
    ▼
③ ConstraintEngine           ← 语义级约束(本篇新增)
    │ PASS
    ▼
执行工具

三层校验各有职责,互不重叠:

层级校验对象例子阻断方式
ToolCallValidator数量"一次调了5个工具"REJECT
_validate_tool_relevance意图相关性"casual_chat 意图调了 shell"REJECT
ConstraintEngine语义正确性"shell 执行了 rm"REJECT 或 WARN

3.2 职责边界

一个常见的设计陷阱是让 ConstraintEngine "什么都管"——这样会导致它变成一个上帝类。我们严格限定其边界:

  • :参数安全、依赖层级、输出格式、重复模式
  • 不管:工具数量(ToolCallValidator 的职责)、意图匹配(_validate_tool_relevance 的职责)

四、前置修复 —— M0 阻塞项解除

4.1 为什么需要先修 ExecutionTracker?

ConstraintEngine 的 max_file_edits_per_task 规则需要查询"最近 N 次调用记录"。这些记录存储在 ExecutionTracker.recent_success_calls 字段中——但代码级评审发现,这个字段没有公开的访问方法

# ExecutionTracker 现有代码(agent.py L77-178)
class ExecutionTracker:
    def __init__(self):
        self.recent_success_calls: list[tuple[str, str, str]] = []
        # ...
    
    def record_success(self, tool_name, action_name, args_hash):
        self.recent_success_calls.append((tool_name, action_name, args_hash))
        # ...
    
    # ❌ 没有 get_recent_calls() 方法!

4.2 修复:3 行代码

def get_recent_calls(self, n: int = 3) -> list[tuple[str, str, str]]:
    """获取最近 N 次成功调用记录 (tool_name, action_name, args_hash)。"""
    return self.recent_success_calls[-n:]

看起来微不足道——3 行代码,但它是一个阻塞项:ConstraintEngine 依赖此方法,没有它就无法获取上下文。

4.3 另一个阻塞项:chat_stream 路径遗漏

代码级评审还发现,chat_stream(流式路径)完全没有调用 _validate_tool_relevance。这意味着流式模式下,模型可以调用任何工具——即使与当前意图完全无关。

# _chat_impl 路径(非流式)— 有校验 ✅
is_relevant, reject_reason = self._validate_tool_relevance(tool_name, action_name)
if not is_relevant:
    # ... 拒绝并 continue

# chat_stream 路径(流式)— 无校验 ❌
# 直接执行工具,没有任何前置校验

修复:在 chat_stream 的工具执行循环中补全 _validate_tool_relevance 调用(约10行),使两条路径对称。

关键洞察:这种"双路径不对称"是大型代码库中最隐蔽的 Bug 类型。非流式路径有完整校验,流式路径却遗漏了——而流式是桌面端和 PWA 的主要执行路径。


五、Hook 注入 —— 嵌入 ReAct 循环

5.1 抽取私有方法

所有 Hook 注入点都抽取为私有方法,供 _chat_implchat_stream 双路径复用:

def _check_constraint(self, tool_name, action_name, arguments, 
                     tc_id, _batch, step_idx, intent_result):
    """ConstraintEngine 前置检查(G1),返回 True 表示被拦截应 continue。"""
    if not self._constraint_engine:
        return False  # 未启用,零开销
    
    result = self._constraint_engine.validate(ToolCallContext(
        tool_name=tool_name,
        action_name=action_name,
        arguments=arguments,
        current_intent=intent_result.primary_intent,
        recent_calls=self._execution_tracker.get_recent_calls(3),
        session_step=step_idx,
    ))
    
    if result.status == "REJECT":
        _batch.append(("tool", result.message, {"tool_call_id": tc_id}))
        return True  # 被拦截,调用方应 continue
    
    if result.status == "WARN":
        logger.warning("ConstraintEngine WARN: %s (rule=%s)", 
                       result.message, result.rule_name)
    
    return False  # 放行

5.2 注入位置

在 ReAct 循环中,ConstraintEngine 检查位于 _validate_tool_relevance 之后、工具执行之前:

# ReAct 循环中的工具处理(两条路径共用模式)

# ① 数量校验
validation = self.tool_validator.validate(tool_calls)
if validation.status == "REJECT":
    continue

# ② 意图相关性校验
is_relevant, reject_reason = self._validate_tool_relevance(tool_name, action_name)
if not is_relevant:
    continue

# ③ 语义约束校验(新增 G1)
if self._check_constraint(tool_name, action_name, arguments,
                          tc_entry["id"], _batch, step_idx, intent_result):
    continue  # 被约束引擎拦截

# ④ 执行工具
result = await self.tool_registry.call_function(...)

5.3 零开销保证

当 ConstraintEngine 未启用时,_check_constraint 的第一行就返回 False——没有任何额外计算。这保证了对现有系统的性能零影响


六、配置驱动灰度

6.1 配置结构

# config/default.toml — 添加在 [agent.trace] 之后

[agent.constraint_engine]
enabled = false                    # 默认关闭
max_file_edits_per_task = 20       # 文件编辑上限
enable_dependency_check = true     # 依赖层级检查
enable_safety_check = true        # 参数安全检查

6.2 灰度策略

# gui_app.py _initialize_core_components() 中
constraint_config = agent_config.get("constraint_engine", {})
if constraint_config.get("enabled", False):
    engine = ConstraintEngine()
    engine.load_rules_from_config(constraint_config)
    self._agent._constraint_engine = engine
    logger.info("ConstraintEngine 已启用,加载 %d 条规则", len(engine._rules))

灰度路径:

  1. 第一周:仅启用 parameter_safety_check(最安全,拦截危险命令)
  2. 第二周:追加 output_format_check(防止文件操作缺少路径)
  3. 第三周:追加 dependency_layer_check(防止跨层调用)
  4. 第四周:追加 max_file_edits_per_task(WARN 模式,观察误报率)

七、与现有安全体系的协同

WeClaw 已有完善的安全体系,ConstraintEngine 不是替代,而是补充:

                    ┌─────────────────┐
                    │  Prompt Security │ ← 输入侧:检测恶意提示注入
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │ Permission Manager│ ← 权限侧:三级风险分类
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │   Audit Logger   │ ← 审计侧:全量记录
                    └────────┬────────┘
                             │
     ┌───────────────────────┼───────────────────────┐
     │                       │                       │
┌────▼─────┐         ┌──────▼──────┐        ┌──────▼──────┐
│ToolCall  │         │_validate_   │        │Constraint   │
│Validator │         │tool_relevance│       │Engine(新增) │
│(数量)    │         │(意图匹配)    │        │(语义约束)   │
└──────────┘         └─────────────┘        └─────────────┘

ConstraintEngine 的独特价值:它是唯一能访问 recent_calls 上下文的校验器。其他校验器只看"当前这一次调用",而 ConstraintEngine 能看到"最近 N 次调用"——这使得重复模式检测成为可能。


八、核心教训

8.1 约束分两种:建议性和强制性

Prompt 是"建议性约束"——模型理解但不保证遵守。代码是"强制性约束"——绕不过去。关键约束必须两层都有:Prompt 告诉模型"为什么"不应该做,ConstraintEngine 确保它"做不到"。

8.2 前置修复的价值

M0 阶段只改了 13 行代码(3+10),但它们是整个方案的基石。如果没有先修 get_recent_calls(),ConstraintEngine 的核心规则就无法工作。最小阻塞项往往是最容易被忽视的。

8.3 WARN 比 REJECT 更难设计

REJECT 是非黑即白的——违反了就拒绝。WARN 是微妙的——什么时候该提醒?提醒太频繁是噪音,太稀疏是失职。我们选择 max_file_edits_per_task 作为唯一 WARN 规则,正是因为它是"建议但不强制"的典型场景。


📖 相关文章


本文是 WeClaw 专栏第四季的第 2 篇(总第 63 篇)。如果这篇文章对你有帮助,欢迎给项目点个 Star ⭐