WeClaw_63_确定性约束引擎:从Prompt引导到代码级强制的范式转变
第四季系列文章第 2 篇(总第 63 篇) - Constraint Engine · 确定性校验 · 语义级约束 · Hook 注入 · 零 token 成本
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 第四季
专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用
本文深入探讨 Harness Engineering 七大差距中的 G1——架构约束自动执行。我们将从"为什么 Prompt 引导不够用"出发,设计一个确定性约束引擎(ConstraintEngine),在工具执行前进行语义级校验,并将这一过程以零 token 成本嵌入 ReAct 循环。
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 💻 项目地址:https://github.com/wyg5208/weclaw.git
- 🌐 官网地址:https://weclaw.link
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
- ⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览: 从 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_impl 和 chat_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))
灰度路径:
- 第一周:仅启用
parameter_safety_check(最安全,拦截危险命令) - 第二周:追加
output_format_check(防止文件操作缺少路径) - 第三周:追加
dependency_layer_check(防止跨层调用) - 第四周:追加
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 ⭐