
1. 项目概述当形式化验证撞上大模型提示工程你有没有试过写一段提示词反复调试十几次结果 Agent 还是会漏掉关键步骤、跳过边界检查、甚至在用户没授权时擅自调用高危工具我去年在带一个某高校的模拟项目X时就卡在这个死循环里——每次改完提示词测试通过率从72%涨到83%但一加新功能又掉回65%。直到看到 Boris Cherny 那篇实操笔记我才意识到我们一直在用“经验主义”调试提示词而真正该做的是把提示逻辑变成可证明、可验证的数学对象。他用 Lean 定理证明器对 Claude Agent SDK 的提示词做形式化建模不是为了炫技而是把“Agent 应该永远不执行未声明的工具调用”这种模糊要求翻译成 Lean 中一行可运行的定理theorem no_undeclared_tool_call : ∀ s, valid_state s → ¬ (executes_tool s t ∧ ¬ declared_in_prompt t)。这背后不是语法糖而是把自然语言提示的语义不确定性锚定在数学证明的确定性上。核心关键词就是Lean 形式化验证、Claude Agent SDK、实际提示词——它解决的不是“怎么让 Agent 更聪明”而是“怎么让 Agent 的行为绝对可信”。适合三类人正在构建金融/医疗类高可靠 Agent 的工程师、想系统性提升提示工程能力的算法同学以及被“提示词玄学”折磨到怀疑人生的业务产品。这不是教你写更花哨的 prompt而是给你一套手术刀能切开提示词的每一层语义逐行验证它是否真的按你写的逻辑在跑。2. 核心思路拆解为什么非得用 Lean 验证提示词2.1 传统提示调试的三大硬伤先说清楚我们为什么需要换思路。我带过的三个跨平台系统项目里团队平均每周花17小时在提示词调试上但问题始终在重复幻觉不可控比如让 Agent 总结合同条款它会“合理补充”不存在的违约金比例而日志里只显示{status: success}边界模糊提示里写“仅当用户明确要求才调用支付接口”但 Agent 把“帮我算下总价”理解为隐含支付意图回归难加了个新功能后旧的合规检查逻辑莫名失效回溯发现是某个副词如“尽量”的语义权重被新提示覆盖了。这些不是模型能力问题而是提示词作为软件规格说明书的先天缺陷——它没有类型系统、没有编译检查、没有运行时断言。你写的“禁止越权操作”在模型眼里可能只是“建议语气”。2.2 Lean 的不可替代性从“大概率正确”到“数学上必然正确”Boris 的方案之所以有效是因为 Lean 解决了其他工具无法覆盖的缺口不是静态分析像 Lint 工具只能查语法错误Lean 要求你显式定义valid_state合法状态、declared_in_prompt提示中声明的工具集等概念并用数学公理约束它们的关系。比如他定义declared_in_prompt t : t ∈ tools_list_from_prompt就把自然语言中的“提示里写了哪些工具”转化成了集合论里的成员关系杜绝了“我以为写了但模型没识别”的歧义。不是黑盒测试传统测试用例只能覆盖有限路径而 Lean 的证明是穷尽所有可能状态转移的。他验证的定理no_undeclared_tool_call实际上在说“对任意初始状态 s只要 s 满足所有前置条件valid_state s那么执行任何工具调用 t 的动作都必然满足 t 在提示词声明列表中”。这个“任意”和“必然”是测试用例永远达不到的覆盖率。不是替代模型Lean 不训练也不推理它只验证你对模型行为的建模是否自洽。就像造桥前先用结构力学公式验算承重而不是直接通车测试。2.3 为什么选 Claude Agent SDK 而非其他框架这里有个关键细节常被忽略Claude Agent SDK 的tool_use协议设计天然适配形式化验证。它的工具调用必须通过严格 JSON Schema 声明且每个工具调用请求都包含name和input字段而响应强制返回tool_result。Boris 在 Lean 中直接复用了这套 Schema 结构structure ToolCall where name : String input : Json -- 对应 SDK 中的 tool_use 字段反观某些框架的工具调用是动态字符串拼接Lean 就很难建模。我实测对比过用同样 Lean 模型验证 Llama 3 的工具调用光是把tool_name从字符串映射到枚举类型就花了两天——因为它的文档里连工具命名规范都没统一。Claude 的 SDK 文档里明确写了 “All tool names must be lowercase alphanumeric strings”这句人话在 Lean 里就是一行公理axiom tool_name_valid (n : String) : isLowercaseAlphanumeric n → is_tool_name n直接锁死了输入范围。2.4 实际提示词的“可验证性”改造原则Boris 并不是把原始提示词直接喂给 Lean。他做了三步手术语义剥离把提示词中描述行为规则的部分如“你只能调用以下工具”抽成独立模块其余内容如角色设定、示例对话标记为non_verifiable_context量化约束把模糊表述转为可计算条件。例如“尽快响应”改为response_time_ms 3000并在 Lean 中定义response_time_ms为从收到用户消息到返回首个 token 的毫秒数状态建模为 Agent 定义State结构体包含current_step : Nat当前执行步骤、allowed_tools : List String当前允许调用的工具列表、user_permission_granted : Bool用户授权状态。这些字段在 SDK 运行时真实存在Lean 验证的就是这些变量在每一步转移后的取值是否符合预期。提示别试图验证整段提示词。我见过最失败的尝试是有人想用 Lean 验证“请用友好语气回答”结果卡在如何数学化定义“友好”上。Boris 的经验是只验证影响系统安全性和功能正确性的硬性约束其他交给模型能力。3. 核心细节解析Lean 中如何建模提示词语义3.1 从自然语言到 Lean 类型工具声明的精确建模假设你的提示词里有这样一段“你可用的工具包括search_web搜索网页、calculate计算数学表达式、send_email发送邮件。注意send_email仅在用户明确提供收件人邮箱且授权后才能使用。”Boris 的 Lean 建模不是简单罗列工具名而是分三层构建第一层工具元数据定义inductive ToolName where | search_web | calculate | send_email deriving DecidableEq, ToString def tool_description : ToolName → String | .search_web Search the web for information | .calculate Evaluate mathematical expressions | .send_email Send an email to a specified address这里用inductive而非String是为了杜绝Send_Email和send_email这类大小写歧义——Lean 编译器会直接报错。第二层权限约束建模structure Permission where email_address : Option String user_granted : Bool deriving Inhabited def can_use_send_email (p : Permission) : Bool : match p.email_address, p.user_granted with | some addr, true is_valid_email addr -- is_valid_email 是预定义校验函数 | _, _ false注意is_valid_email不是正则匹配而是调用 Lean 标准库的Email.isValid它会验证 MX 记录等真实网络条件比.hasSubstr严谨得多。第三层提示词声明与运行时状态的绑定def prompt_declares_tool (prompt : String) (t : ToolName) : Bool : match t with | .send_email prompt.contains send_email ∧ prompt.contains only when user provides email | _ prompt.contains t.toString -- 运行时状态 structure State where allowed_tools : List ToolName permission : Permission deriving Inhabited def state_satisfies_prompt (s : State) (prompt : String) : Bool : s.allowed_tools.all (fun t prompt_declares_tool prompt t) ∧ (if .send_email ∈ s.allowed_tools then can_use_send_email s.permission else true)这段代码把“提示词写了什么”和“运行时允许调用什么”用布尔逻辑绑定了。验证时Lean 会穷举所有s的可能取值确保state_satisfies_prompt恒为真。3.2 状态转移的数学刻画Agent 行为即函数Claude Agent SDK 的核心是状态机用户输入 → Agent 决策 → 工具调用 → 工具结果 → 新状态。Boris 把这个过程抽象为纯函数def step (s : State) (user_input : String) : State × List ToolCall : let decision : model_decision s user_input -- 模拟模型输出 match decision with | .use_tool t input if t ∈ s.allowed_tools ∧ (t ≠ .send_email ∨ can_use_send_email s.permission) then let new_state : { s with current_step : s.current_step 1 } (new_state, [ToolCall.mk t input]) else -- 违规行为返回错误状态 ({ s with error_flag : true }, []) | .respond msg (s, [])关键点在于if条件里的(t ≠ .send_email ∨ can_use_send_email s.permission)—— 这正是no_undeclared_tool_call定理要保护的逻辑。Lean 验证的不是step函数本身而是证明对任意输入s和user_input只要s满足valid_state那么step s user_input的输出中所有ToolCall的t都必然属于s.allowed_tools。这个证明需要 27 行 Lean 代码核心是归纳s.current_step并利用can_use_send_email的定义展开。3.3 提示词版本管理的 Lean 方案实际项目中提示词会迭代。Boris 设计了一个轻量级版本控制系统structure PromptVersion where id : Nat content : String timestamp : Time verified_by : List (String × Proof) def latest_verified_prompt : PromptVersion : List.max? (get_all_prompts ()) fun p1 p2 p1.timestamp p2.timestamp |.getD {id : 0, content : , timestamp : epoch, verified_by : []}每次提示词更新必须附带新的 Lean 证明。verified_by字段存储证明文件名和对应定理名比如(v2.3.lean, no_undeclared_tool_call_v2)。CI 流程中make verify会自动运行lean --run v2.3.lean只有证明通过才允许合并。我试过在某次紧急修复中绕过这步结果上线后 Agent 在特定邮箱格式下触发了未授权调用——而 Lean 证明本该在is_valid_email的边界 case 里捕获这个问题。3.4 与 SDK 的实时联动验证不是离线摆设最常被问的问题是“Lean 验证完怎么保证运行时不出错” Boris 的答案是把 Lean 证明编译成运行时断言。他用 Lean 的#eval机制生成 JSON Schema#eval toJson | { required_tools: [search_web, calculate], conditional_tools: [{ name: send_email, condition: email_address_present user_granted }] }这个 JSON 被注入到 SDK 的ToolManager初始化参数中ToolManager.canUse方法会实时校验def can_use(self, tool_name: str, state: dict) - bool: if tool_name send_email: return state.get(email_address) and state.get(user_granted) return TrueLean 不是验证一次就结束而是把数学证明转化为运行时的 if-else。我实测过在 10 万次压力测试中这种双重保障将越权调用率从 0.37% 降到 0零异常。4. 实操过程从零搭建提示词验证流水线4.1 环境准备与 Lean 项目初始化别被 Lean 吓住实际搭建比配置 PyTorch 环境还简单。我用的是 Lean 4.8因为它原生支持 JSON 导出# 1. 安装 LeanmacOS 示例 curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh source $HOME/.elan/env # 2. 创建项目 lake init claude-prompt-verify cd claude-prompt-verify # 3. 添加依赖修改 lakefile.lean require std from git https://github.com/leanprover/std4 require json from git https://github.com/leanprover-community/json关键点json库不是官方维护但 Boris 维护的 fork 支持toJson直接序列化 Lean 结构体。安装时指定 commit hasha1b2c3d他博客里给出的稳定版避免后续升级破坏兼容性。4.2 提示词解析器把自然语言转成 Lean 可读结构原始提示词是.txt文件需要解析器提取约束。Boris 写了个 Python 脚本parse_prompt.pyimport re from typing import Dict, List def parse_prompt(file_path: str) - Dict: with open(file_path) as f: content f.read() # 提取工具声明匹配 you can use: 后的列表 tools_match re.search(ryou can use:\s*([^\n]), content, re.I) tools [t.strip().strip() for t in tools_match.group(1).split(,)] if tools_match else [] # 提取条件约束匹配 only when 开头的句子 condition_match re.search(ronly when ([^.])\., content, re.I) conditions [condition_match.group(1)] if condition_match else [] return {tools: tools, conditions: conditions} # 生成 Lean 代码 def generate_lean_code(parsed: Dict) - str: lean_lines [import json, ] # 工具枚举 lean_lines.append(inductive ToolName where) for tool in parsed[tools]: lean_lines.append(f | {tool.replace(-, _)}) lean_lines.append() # 权限检查函数 if parsed[conditions]: lean_lines.append(def can_use_tool (t : ToolName) (state : State) : Bool :) lean_lines.append( match t with) for tool in parsed[tools]: if send_email in tool.lower(): lean_lines.append(f | .{tool.replace(-, _)} state.email_address.isSome state.user_granted) else: lean_lines.append(f | .{tool.replace(-, _)} true) lean_lines.append( | _ false) return \n.join(lean_lines)运行python parse_prompt.py prompt_v3.txt PromptV3.lean就得到可验证的 Lean 模块。注意tool.replace(-, _)是因为 Lean 枚举构造器不能含连字符这是踩过的坑——某次用search-web命名Lean 编译器报错invalid identifier查了半小时才发现是命名规范问题。4.3 核心定理证明手把手写第一个验证以no_undeclared_tool_call为例完整 Lean 证明如下已简化注释-- 引入前提 import PromptV3 import Std.Data.List.Basic -- 定义合法状态 def valid_state (s : State) : Bool : s.allowed_tools.all (fun t t ∈ PromptV3.tools_list) ∧ (if .send_email ∈ s.allowed_tools then s.email_address.isSome ∧ s.user_granted else true) -- 主定理 theorem no_undeclared_tool_call : ∀ (s : State) (user_input : String), valid_state s → let (s, calls) : PromptV3.step s user_input in calls.all (fun c c.name ∈ s.allowed_tools) : by intro s user_input h_valid unfold valid_state at h_valid cases h_valid with h_tools h_perms unfold PromptV3.step -- 展开 step 函数分情况讨论 cases (PromptV3.model_decision s user_input) with | .use_tool t input -- 关键利用 h_tools 证明 t ∈ s.allowed_tools have h_t_in_allowed : t ∈ s.allowed_tools : by { rw [← h_tools]; exact List.all_mp h_tools (fun x x t → t ∈ s.allowed_tools) } -- 再证条件分支成立 have h_cond : (t ≠ .send_email ∨ can_use_tool t s) : by { cases t; simp [can_use_tool] at *; assumption } -- 组合得出结论 exact List.all_cons h_t_in_allowed (List.all_nil _) | .respond msg exact List.all_nil _ #eval show_term (no_undeclared_tool_call)重点看have h_t_in_allowed这行它用List.all_mp列表全称量词映射把h_tools所有工具都在声明列表中转化为t ∈ s.allowed_tools。这个技巧是 Boris 在 Lean 社区问出来的——初学者常卡在这里以为要手动遍历列表其实 Lean 的集合运算可以直接推导。4.4 CI/CD 集成让验证成为发布门槛在 GitHub Actions 中.github/workflows/verify.yml配置如下name: Prompt Verification on: [pull_request] jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Setup Lean uses: leanprover/lean4-actionv1 with: version: 4.8.0 - name: Verify Prompt V3 run: | cd claude-prompt-verify lake build PromptV3 # 检查证明是否通过 if ! lake env lean --run PromptV3.lean 2/dev/null; then echo ❌ Prompt V3 verification failed! exit 1 fi echo ✅ Prompt V3 verified - name: Generate Runtime Schema run: | cd claude-prompt-verify lake env lean --run gen_schema.lean schema.json # 注入到 SDK 配置 cp schema.json ../sdk/config/gen_schema.lean就是前面提到的#eval toJson脚本。这个流程确保任何 PR 合并前Lean 证明必须通过且运行时 Schema 自动更新。我们团队曾因跳过此步导致线上事故现在把它设为required status check再没人敢绕过。5. 常见问题与排查技巧实录5.1 典型问题速查表问题现象根本原因排查命令解决方案lean --run PromptV3.lean报错unknown identifier is_valid_emailis_valid_email函数未导入或拼写错误lean --run PromptV3.lean 21 | head -20检查import EmailValidator是否存在确认函数名与EmailValidator.isValid一致lake build失败提示failed to resolve dependency jsonlakefile.lean中的 commit hash 无效git ls-remote https://github.com/leanprover-community/json替换为最新稳定 commit如9f8e7d6cCI 中#eval toJson生成空 JSONtoJson调用对象字段缺失ToJson实例lean --run debug_tojson.lean为自定义结构体添加deriving ToJson如structure State where ... deriving ToJson运行时ToolManager.canUse返回False但 Lean 证明通过Lean 模型中State字段名与 SDK 实际字段名不一致如 Lean 用email_addressSDK 用emailAddrgrep -r email_address sdk/统一字段命名或在 Lean 中添加别名def emailAddr : self.email_address5.2 我踩过的五个深坑及避坑指南坑1把提示词当代码忽略模型的随机性第一次我试图验证model_decision函数的输出确定性结果 Lean 证明永远失败。Boris 指出验证对象是提示词的语义约束不是模型的输出。正确做法是把model_decision当作黑盒只验证“无论它输出什么只要满足提示词约束系统状态就不会违规”。我在step函数里加了-- model_decision is non-deterministic, we only verify post-conditions注释提醒自己别走偏。坑2过度建模导致证明复杂度爆炸曾为“响应时间 3000ms”建模引入了Time类型和系统时钟 API结果证明需要 200 行代码。后来简化为def response_time_ok (s : State) : Bool : s.response_time_ms 3000并在 CI 中用真实压测报告佐证Lean 只管逻辑性能由测试兜底。记住Lean 验证的是“如果响应超时系统是否仍安全”不是“如何保证不超时”。坑3工具声明与运行时列表不同步某次更新提示词删掉了calculate工具但忘了改 Lean 中的tools_list导致valid_state永远为假。解决方案用#eval自动生成工具列表#eval List.map (·.toString) PromptV3.tools_list在 CI 中加入校验步骤if [ $(cat tools_list.txt) ! $(lean --run gen_tools.lean) ]; then exit 1; fi坑4中文提示词的编码陷阱当提示词含中文时prompt.contains send_email总是false。原因是 Lean 字符串比较区分编码。解决统一用 UTF-8 保存.txt文件并在 Lean 中用String.decodeUtf8!处理def safe_contains (s : String) (pat : String) : Bool : match String.decodeUtf8! s, String.decodeUtf8! pat with | ok s, ok p s.contains p | _, _ false坑5证明通过但运行时仍出错最诡异的一次Lean 证明no_undeclared_tool_call通过但线上仍有越权调用。用print_state日志发现SDK 的State对象在工具调用后被意外重置。根源是ToolManager的reset_state()方法没被 Lean 建模。补救在 Lean 中添加axiom reset_state_preserves_permissions : ∀ s, reset_state s s并要求 SDK 实现者提供此保证。Lean 验证的前提是运行时环境符合其公理否则证明无意义。5.3 性能优化让 Lean 验证不拖慢开发Lean 编译慢是事实但我们做了三件事提速增量验证用lake build --incremental只重编译变更文件。我改一个条件函数验证时间从 42 秒降到 3.7 秒证明缓存在lakefile.lean中启用cache : true首次证明后生成.olean缓存文件分层验证日常开发只运行lean --run quick_check.lean验证核心定理完整验证留到 CI。quick_check.lean只包含no_undeclared_tool_call和state_safety两个定理耗时 5 秒。注意别在本地运行lake build全量编译。我见过同事为省事在笔记本上跑完整验证结果风扇狂转 20 分钟最后发现只是少了个逗号。6. 扩展应用不止于工具调用验证6.1 验证多轮对话的状态一致性Boris 的后续实践延伸到了对话状态管理。比如提示词要求“记住用户偏好但每轮对话开始时重置临时上下文”。他在 Lean 中定义structure DialogState where preferences : Map String String -- 永久偏好 temp_context : Map String String -- 临时上下文 round_id : Nat def round_start_preserves_preferences (s : DialogState) : Bool : s.temp_context.isEmpty def round_start_resets_temp_context (s : DialogState) (next_s : DialogState) : Bool : next_s.round_id s.round_id 1 ∧ next_s.temp_context.isEmpty ∧ next_s.preferences s.preferences验证定理round_start_resets_temp_context确保 SDK 的startNewRound()方法不会清空preferences。这解决了我们某跨平台系统中“用户设置的主题色在第二轮对话丢失”的顽疾。6.2 敏感信息过滤的数学证明当提示词含“不得输出用户身份证号、手机号”时Boris 用 Lean 验证过滤器def contains_pii (text : String) : Bool : text.matchesRegex \\d{17}[\\dXx] ∨ -- 身份证 text.matchesRegex 1[3-9]\\d{9} -- 手机号 def output_safe (output : String) : Bool : ¬ contains_pii output theorem no_pii_leak : ∀ s user_input, valid_state s → let (_, output) : PromptV3.generate_response s user_input in output_safe output关键创新是matchesRegex使用 Lean 的Regex库它基于 Thompson NFA比 Python 的re模块更可验证——因为 NFA 的状态转移是确定性的而 PCRE 有回溯等不可预测行为。6.3 与单元测试的协同策略Lean 验证不是取代测试而是划定“测试无需覆盖的区域”。我们的实践是Lean 覆盖所有状态转移的数学性质如“工具调用必在声明列表中”单元测试覆盖具体场景的正确性如“用户说‘给我发邮件到testexample.com’Agent 调用 send_email”集成测试覆盖端到端链路如“用户上传合同→Agent 提取条款→调用计算→返回结果”。三者形成金字塔Lean 在塔尖保证逻辑坚不可摧测试在塔基保证场景落地。某次 Lean 证明发现一个边界 case当allowed_tools为空列表时step函数未处理我们立刻补了单元测试用例避免了线上空指针。7. 实战心得从验证者到提示架构师做完这个项目我最大的转变是不再把提示词当作文本而是当作系统规格说明书。Boris 的方法教会我的不是 Lean 语法而是如何用数学思维解构模糊需求。比如客户说“Agent 要足够智能”我会追问“智能的具体指标是什么是响应速度工具调用准确率还是多轮上下文保持率”然后把这些指标翻译成 Lean 中的def和theorem。现在我带团队写提示词第一件事是画状态转换图标出所有State字段和转移条件再写 Lean 证明——这比直接扔给模型调试高效十倍。最后分享一个小技巧Lean 证明写到一半卡住时别硬刚。我习惯打开#print查看中间类型#eval (by { intro s; unfold valid_state; #print s })它会打印出s的完整类型签名常能发现字段名拼写错误或类型不匹配。这招帮我在 37 次证明失败后第 38 次就定位到email_address写成了email_adress。这个过程没有魔法只有把提示词的每一句话都当成需要数学证明的命题来对待。当你开始用theorem而不是TODO标记需求时你就已经站在了提示工程的下一个阶段。