硬约束,软引导——HippoBuddy 框架的设计哲学
哪些东西该管,哪些不该管——以及怎么管
上一篇提到一个设计思想:硬约束,软引导——框架层面做硬性的安全约束,工具返回的信息做软性的引导。这篇来展开聊聊这个思想背后的判断逻辑,以及它在 HippoBuddy 中的落地过程。
先讲一段走不通的路
早期 HippoBuddy 有个设计:编辑文件之前必须先读取。设计初衷听起来很合理——防止 LLM 在不了解文件内容的情况下胡乱编辑。这个约束体现在两个层面:
- Prompt 层:在系统提示词里写"编辑文件前请先读取文件内容"
- 框架层:在工具调用链路中加了检查逻辑,如果没有前置的读文件操作,编辑请求会被拦截
结果呢?
Prompt 约束基本不起作用。LLM 在训练数据中形成的"思维模式"远比 prompt 中的几句指令强大——它按照自己理解的方式来,不会因为 prompt 里写了一句"先读后写"就乖乖照做。更讽刺的是,那些真正需要先读后写的场景,LLM 本来就会先读文件再编辑,根本不需要约束。这个约束实际上只是在少数边缘场景中起作用,却让所有正常的编辑操作都多了一层无意义的检查。
框架层面的约束更糟糕——它试图教 LLM 做事,但框架不可能穷举所有需要先读后写的场景,也不可能判断 LLM 是否"真的了解"文件内容。最终这个约束被完全移除。
这段经历引出一个核心问题:工具设计中的约束,到底该管什么,不该管什么?
为什么是"硬约束,软引导"
回答这个问题之前,先想清楚一个前提:LLM 比你聪明。
这不是一句口号,而是设计约束时的基本假设。如果把 LLM 当成一个需要手把手教的新手,就会倾向于在 prompt 里写满"你应该先做 A,再做 B,如果遇到 C 就做 D……"——这恰恰是早期 HippoBuddy 走过的弯路。LLM 有自己的判断力,来自海量训练数据的思维模式远比你写在 prompt 里的几条规则更可靠。
那框架还需要做什么?两件事:
- 守住底线——LLM 再聪明,也可能犯错。安全、权限、数据完整性这些一旦出问题就不可逆的事情,需要框架来兜底。
- 给反馈,不给指令——当工具调用出错时,把错误信息清晰地返回给 LLM,让它自己判断下一步怎么做,而不是替它做决定。
这就是"硬约束,软引导"的实质:
- 硬约束:框架层面强制执行的规则,不可绕过。LLM 无法干预,也不需要 LLM 理解——只要遵守就行。
- 软引导:信息层面的提示和建议。LLM 可以采纳,也可以不采纳——判断权在 LLM 手里。
两者的本质区别不在于"约束力强弱",而在于谁来承担判断责任。硬约束的责任在框架,软引导的责任在 LLM。
判断标准:四个问题
什么样的规则该做成硬约束?什么样的该做成软引导?实践中总结出四个判断问题:
1. 后果有多严重?
如果违反规则会导致:数据丢失、权限泄露、系统崩溃 → 硬约束
如果违反规则会导致:多花几轮对话、效率降低、不够优雅 → 软引导
安全、权限、数据完整性是不可商量的底线,必须由框架强制执行。工具调用的效率、参数的格式偏好、调用顺序的优化——这些属于"建议"范畴,LLM 有能力自己判断。
2. 模型有能力自己判断吗?
如果 LLM 天然就能做对(或者做错了也能自我纠正)→ 软引导
如果 LLM 缺乏判断所需的信息(比如不知道哪些命令是危险的)→ 硬约束
"先读后编辑"属于前者——LLM 在训练数据中见过无数"先读取再修改"的模式,不需要额外约束。"rm -rf / 不能执行"属于后者——LLM 可能知道这危险,但在实际调用中可能因为各种原因(参数拼接错误、误解路径等)触发,需要框架拦截。
3. 误判的成本有多高?
如果误判(应拦未拦)的代价不可接受 → 硬约束
如果误判的代价可控(最多浪费几个 token)→ 软引导
一个命令被错误拦截,用户最多抱怨一句"怎么这都不让执行";但一个危险命令被错误放行,可能导致数据丢失。前者的成本是体验层面的,后者的成本是不可逆的。
4. 这个规则会过时吗?
如果规则稳定、不太可能随模型迭代改变 → 硬约束
如果规则可能随模型能力提升而变得不再必要 → 软引导
"不允许删根目录"这个规则永远不会过时。"提醒模型先读取再编辑"这个规则——随着模型能力提升,需要提醒的场景会越来越少。
硬约束实战:BlockerChain 的三把锁
HippoBuddy 的硬约束设计集中在 BlockerChain 这个责任链中。它像一道安检门,每个工具调用都会经过这里,由多个 Blocker 依次检查。
选择"责任链"模式本身就是一种设计取舍:硬约束应该独立、可组合、可观测。每个 Blocker 只关注一个维度的安全,互不干扰;BlockerChain 可以随时增减 Blocker;每个 Blocker 的执行时间和拦截次数都有统计,方便判断约束是否"过期"。
第一把锁:BashDangerousCommandBlocker
这是最复杂的一个 Blocker,采用六级分级检查:
一级:命令替换注入(`、$()) → 直接拦截
二级:高危模式(rm -rf / 等) → 直接拦截
三级:本地脚本执行(./ 开头) → 需要用户确认
四级:严格禁止名单(format、shutdown)→ 直接拦截
五级:需确认名单(rm、kill 等) → 需要用户确认
六级:白名单(git、ls 等) → 自动放行
默认:未知命令 → 需要用户确认
这个分级体现了一个重要原则:硬约束不是一刀切的"拦或不拦"。对于明显危险的操作(命令注入、高危模式),框架直接拦截,不给 LLM 任何回旋余地;对于有正当用途但有风险的操作(删除文件、执行本地脚本),框架不替 LLM 做决定,而是交给用户确认;对于安全操作,框架不干预。
第二把锁:ConcurrentEditBlocker
并发编辑保护。当多个工具调用并发执行时,防止同时对同一个文件进行写操作。
这个 Blocker 的设计特别简单——检查文件是否被锁定,如果锁定就返回一个带示例的引导错误,否则放行。简单是因为它不需要理解业务逻辑,只需要知道"这个文件现在能不能动"。
第三把锁:SchemaValidationBlocker
参数校验。检查工具调用是否缺少必需参数、参数类型是否正确。
这个 Blocker 虽然叫"校验",但它返回的错误信息本身就是硬约束和软引导的结合体:拦截无效调用(硬),同时给出正确的参数示例(软)。比如拦截一个缺少 path 参数的 read_file 调用时,它会返回:
⛔ 执行被阻断
❌ 原因: 缺少必需参数: path
💡 示例: {"path": "src/main/java/com/example/Example.java"}
💡 部分就是软引导——它不告诉 LLM "你应该先检查参数再调用",而是直接告诉 LLM "正确的参数长这样",让 LLM 自己判断怎么修正。
硬约束设计的三个要点
从这三把锁的设计中,可以总结出硬约束的三个要点:
1. 只守底线,不教做事
硬约束只拦截那些"一旦发生就不可逆"的操作。它不问"你为什么要这么做",也不判断"这么做是不是最优解"——它只检查"这个操作能不能做"。
2. 静默执行,不消耗 LLM 的注意力
BlockerChain 的执行时间在 1ms 级别,对 LLM 完全透明。硬约束不应该成为 LLM 需要感知到的存在——它应该像地基一样,在看不见的地方起作用。
3. 可观测,可调整
每个 Blocker 的执行耗时、拦截次数都有统计。如果一个 Blocker 长期零拦截,或者它的拦截率异常升高,都说明需要重新审视这个约束是否还有必要。硬约束也应该带着"过期日期"去设计。
软引导实战:错误信息的迭代
如果说硬约束是"什么不能做",那软引导就是"怎么做才对"。
第一道软引导其实藏在工具定义(description)里——在 LLM 调用工具之前就生效。以 bash 为例,它的 description 不仅说明"支持什么",还明确列出"严禁什么":
⚠️ 严禁执行系统级破坏性命令(format/fdisk/diskpart/dd/shutdown/reboot/halt/poweroff/mkfs)、
递归删除系统/根/家目录(rm -rf /、rm -rf ~、del /s 系统盘)、命令替换注入(`、$())、
磁盘擦除(cipher /w、shred、sdelete)、清空防火墙(iptables -F、ufw disable)、
管道下载执行(curl/wget | bash|sh)——这些操作会被安全机制直接拦截,不要尝试。
删除/覆盖/提权(sudo/su)/强杀进程(kill/taskkill)等操作可能触发用户确认。
这段描述是给 LLM 的软引导:它不代替 Blocker 做硬拦截,而是让 LLM 在生成工具调用时主动避开这些命令,从源头减少触发确认弹窗和被拦截报错。硬拦截(Blocker)兜底"LLM 没听劝"的情况,软引导(description)减少"LLM 压根不该试"的情况——两者互补,不是替代。
HippoBuddy 的软引导实践主要体现在工具返回的错误信息上。HookResult 类提供了五种返回值,每种对应不同的引导策略:
| 返回值 | 含义 | 引导策略 |
|---|---|---|
allow() | 放行 | 无 |
warn() | 放行但带警告 | 软提醒,不阻断 |
validationError() | 参数错误 | 错误原因 + 正确示例 |
block() | 禁止执行 | 只报错误码,不给指导 |
requireConfirmation() | 需要用户确认 | 转交用户决策 |
其中最有意思的是 block() 和 validationError() 的对比:
block():只告诉 LLM "这个操作被禁止了",不给任何替代方案或修正建议。这是因为被禁止的操作(如命令注入)不存在"正确的调用方式",LLM 不应该尝试修正调用参数,而应该换个思路。validationError():告诉 LLM "参数不对,正确的参数应该这样写"。这是引导 LLM 修正调用行为,而不是替它修正。
这个区分背后的逻辑是:错误信息的责任不是帮 LLM 解决问题,而是给 LLM 足够的信息让它自己解决问题。
早期的错误信息会更啰嗦,比如"请检查您的参数是否正确,确保 path 字段不为空,path 应该是字符串类型……"——这像是写给开发者看的错误提示,但 LLM 不是开发者。后来精简为:
⛔ 执行被阻断
❌ 原因: 缺少必需参数: path
💡 示例: {"path": "src/main/java/com/example/Example.java"}
三段式结构:标识 → 原因 → 示例。标识让 LLM 一眼知道"出错了",原因告诉它"哪里错了",示例告诉它"怎么是对的"。不多说一句废话,判断权完全交给 LLM。
灰色地带:requireConfirmation
requireConfirmation 是一个特殊的中间态——它既不是硬约束也不是软引导,而是把决策权转交给用户。
当框架判断一个操作"可能有风险但不确定是否有害"时(比如执行 rm 命令、运行本地脚本),它不会自己决定拦截还是放行,而是告诉 LLM "这个操作需要用户点头",然后暂停等待用户确认。
这个设计的巧妙之处在于:框架避免了替用户做价值判断。"删除文件"在某些场景下是合理的(清理临时文件),在某些场景下是破坏性的。框架没有足够的信息来判断当前场景属于哪一种,但用户有。
requireConfirmation 本质上是一种免责机制:框架守住"不能无声无息地执行危险操作"这条底线,同时把最终决策权交还给有能力做判断的人。
回过头看
回到开头的"先读后编辑"的例子。为什么那个约束走不通?
用四个问题重新审视它:
- 后果有多严重? → LLM 在没读文件的情况下编辑文件,最多是改错了能恢复。不严重 → 不该硬约束
- 模型能自己判断吗? → LLM 天然知道需要先了解文件内容再修改 → 不该硬约束
- 误判成本多高? → 拦截了一个正常编辑,只是多花一轮对话 → 不该硬约束
- 会过时吗? → 随着模型能力提升,这个约束越来越多余 → 不该硬约束
四条全中"不该硬约束"。它实际上是个软引导问题——当 LLM 真的在不了解文件内容的情况下编辑时,工具的错误信息应该帮助它意识到"这次编辑出了问题",而不是在事前阻止它动手。
不止于工具:设计哲学在框架各层的映射
"硬约束,软引导"并非工具层的专属设计思想。翻看 HippoBuddy 的代码库,会发现这个模式在框架的各层反复出现——它更像是 HippoBuddy 在长期迭代中沉淀出的一种框架级设计惯性。
StopHook 层:stop vs warn
StopHook.java
stop("检测到死循环,强制终止") → 硬约束:立刻停止,不可继续
warn("工具调用趋势异常") → 软引导:只发警告,把判断权留给对话循环
StopHook 是 BlockerChain 在更高层级的镜像——两者的返回值结构几乎一样(stop/continue/warning 对应 block/allow/warn)。这不是巧合,而是同一设计思想在不同抽象层的自然投射。
并发执行层:文件锁 vs 回调通知
ConcurrentToolExecutor.java
硬约束:FileLockManager 文件锁
— 同一时间只有一个工具能写同一个文件
软引导:ToolExecutionCallback 回调
— 通知上游"工具执行完了",但不控制下一步做什么
硬约束:后台任务与前台任务分离
— 需要用户等待的操作不能后台静默执行
软引导:结果按 index 排序后返回
— 避免 LLM 因为工具返回顺序混乱而产生困惑
有趣的是,文件锁本身也是"硬约束"——它作用于工具层面(ConcurrentEditBlocker 检查锁状态),也作用于并发执行层面(ConcurrentToolExecutor 管理锁)。同一个保护机制,在两个层面上都遵循相同的设计思路。
看本质
这些例子不是在说"代码里到处都是硬约束和软引导"——而是说:当你对一套设计思想足够笃定,它会在不知不觉中渗透到每个层次的决策里。
看这些例子的共同点:
- 硬约束都在保护"不可逆"的东西:StopHook 在保护"不会无限死循环",文件锁在保护"不会数据冲突"
- 软引导都在给"下一步怎么走"提供信息:发出警告、回调通知——只给信息,不做决定
- 两者之间的界线非常清晰:强制终止 vs 发出警告,锁定文件 vs 通知完成
这说明"硬约束,软引导"不是一个需要刻意遵循的设计模式,而是理解框架各层"该做什么、不该做什么"之后自然长出来的东西。
总结
硬约束和软引导不是对立的,它们是工具设计中两个互补的维度:
- 硬约束守底线——安全、权限、数据完整性,这些是框架不可推卸的责任
- 软引导给反馈——通过精简清晰的错误信息,让 LLM 自己判断下一步怎么走
- 灰色地带交给用户——拿不准的决策,由用户来拍板
工具设计的核心不是"怎么约束 LLM",而是**"怎么在给 LLM 最大自由的同时守住不可逾越的底线"**。框架不该教 LLM 做事,LLM 比框架聪明得多;但框架要在 LLM 犯错时兜住最坏的结果,并且用 LLM 能理解的方式告诉它"发生了什么"。
把判断权还给 LLM,把底线留给框架——这就是"硬约束,软引导"的全部含义。