👋 Hi,带娃的我热爱AI 大模型应用落地、意识解码与 AI 开发工具链。 💡 创业路上,用技术换时间,一起把 AI 变成生产力 🚀 >
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 核心数据结构
@dataclassclassConstraintRule:"""一条确定性约束规则。"""name:str# 规则名称severity:str# "REJECT" | "WARN"check_fn:Callable[[ToolCallContext],bool]# 校验函数message_template:str# 告警/拒绝消息模板@dataclassclassToolCallContext:"""工具调用的完整上下文,供规则函数使用。"""tool_name:straction_name:strarguments:dictcurrent_intent:strrecent_calls:list[tuple[str,str,str]]# 最近N次调用记录session_step:int# 当前步骤序号@dataclassclassConstraintResult:"""约束校验结果。"""status:str# "PASS" | "REJECT" | "WARN"message:str# 告警/拒绝原因rule_name:str# 触发的规则名2.2 规则引擎核心
classConstraintEngine:"""确定性约束规则引擎。在工具执行前由 ReAct 循环直接调用。"""def__init__(self):self._rules:list[ConstraintRule]=[]defvalidate(self,ctx:ToolCallContext)->ConstraintResult:"""逐条规则校验,返回第一个 REJECT 或最后一个 WARN。"""last_warn=Noneforruleinself._rules:ifrule.check_fn(ctx):ifrule.severity=="REJECT":returnConstraintResult(status="REJECT",message=rule.message_template.format(**ctx.__dict__),rule_name=rule.name,)elifrule.severity=="WARN"andnotlast_warn:last_warn=ConstraintResult(status="WARN",message=rule.message_template.format(**ctx.__dict__),rule_name=rule.name,)iflast_warn:returnlast_warnreturnConstraintResult(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=lambdactx:(ctx.tool_name=="shell"andany(kwinstr(ctx.arguments.get("command",""))forkwin["model_registry","agent_pool"])),message_template="禁止 shell 工具直接操作内部组件: {tool_name}",),# 规则2:输出格式检查# 文件生成类工具必须包含合法路径参数ConstraintRule(name="output_format_check",severity="REJECT",check_fn=lambdactx:(ctx.action_namein("write","create","save")andnotctx.arguments.get("path","")),message_template="文件操作缺少合法 path 参数: {action_name}",),# 规则3:参数安全检查# shell 工具禁止危险命令模式ConstraintRule(name="parameter_safety_check",severity="REJECT",check_fn=lambdactx:(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=lambdactx:(ctx.action_namein("write","edit")andsum(1forcinctx.recent_callsifc[0]=="file"andc[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)classExecutionTracker:def__init__(self):self.recent_success_calls:list[tuple[str,str,str]]=[]# ...defrecord_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 行代码
defget_recent_calls(self,n:int=3)->list[tuple[str,str,str]]:"""获取最近 N 次成功调用记录 (tool_name, action_name, args_hash)。"""returnself.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)ifnotis_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。"""ifnotself._constraint_engine:returnFalse# 未启用,零开销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,))ifresult.status=="REJECT":_batch.append(("tool",result.message,{"tool_call_id":tc_id}))returnTrue# 被拦截,调用方应 continueifresult.status=="WARN":logger.warning("ConstraintEngine WARN: %s (rule=%s)",result.message,result.rule_name)returnFalse# 放行5.2 注入位置
在 ReAct 循环中,ConstraintEngine 检查位于_validate_tool_relevance之后、工具执行之前:
# ReAct 循环中的工具处理(两条路径共用模式)# ① 数量校验validation=self.tool_validator.validate(tool_calls)ifvalidation.status=="REJECT":continue# ② 意图相关性校验is_relevant,reject_reason=self._validate_tool_relevance(tool_name,action_name)ifnotis_relevant:continue# ③ 语义约束校验(新增 G1)ifself._check_constraint(tool_name,action_name,arguments,tc_entry["id"],_batch,step_idx,intent_result):continue# 被约束引擎拦截# ④ 执行工具result=awaitself.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",{})ifconstraint_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_62_Harness工程全景:六层模型评估与WeClaw 4.3分成熟度诊断
- WeClaw_60_能力越强护栏越硬:AI自主进化的安全边界工程与核能类比
本文是 WeClaw 专栏第四季的第 2 篇(总第 63 篇)。如果这篇文章对你有帮助,欢迎给项目点个 Star ⭐