跳到主要内容

硬约束,软引导——HippoBuddy 框架的设计哲学

哪些东西该管,哪些不该管——以及怎么管


上一篇提到一个设计思想:硬约束,软引导——框架层面做硬性的安全约束,工具返回的信息做软性的引导。这篇来展开聊聊这个思想背后的判断逻辑,以及它在 HippoBuddy 中的落地过程。

先讲一段走不通的路

早期 HippoBuddy 有个设计:编辑文件之前必须先读取。设计初衷听起来很合理——防止 LLM 在不了解文件内容的情况下胡乱编辑。这个约束体现在两个层面:

  • Prompt 层:在系统提示词里写"编辑文件前请先读取文件内容"
  • 框架层:在工具调用链路中加了检查逻辑,如果没有前置的读文件操作,编辑请求会被拦截

结果呢?

Prompt 约束基本不起作用。LLM 在训练数据中形成的"思维模式"远比 prompt 中的几句指令强大——它按照自己理解的方式来,不会因为 prompt 里写了一句"先读后写"就乖乖照做。更讽刺的是,那些真正需要先读后写的场景,LLM 本来就会先读文件再编辑,根本不需要约束。这个约束实际上只是在少数边缘场景中起作用,却让所有正常的编辑操作都多了一层无意义的检查。

框架层面的约束更糟糕——它试图教 LLM 做事,但框架不可能穷举所有需要先读后写的场景,也不可能判断 LLM 是否"真的了解"文件内容。最终这个约束被完全移除。

这段经历引出一个核心问题:工具设计中的约束,到底该管什么,不该管什么?

为什么是"硬约束,软引导"

回答这个问题之前,先想清楚一个前提:LLM 比你聪明

这不是一句口号,而是设计约束时的基本假设。如果把 LLM 当成一个需要手把手教的新手,就会倾向于在 prompt 里写满"你应该先做 A,再做 B,如果遇到 C 就做 D……"——这恰恰是早期 HippoBuddy 走过的弯路。LLM 有自己的判断力,来自海量训练数据的思维模式远比你写在 prompt 里的几条规则更可靠。

那框架还需要做什么?两件事:

  1. 守住底线——LLM 再聪明,也可能犯错。安全、权限、数据完整性这些一旦出问题就不可逆的事情,需要框架来兜底。
  2. 给反馈,不给指令——当工具调用出错时,把错误信息清晰地返回给 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 本质上是一种免责机制:框架守住"不能无声无息地执行危险操作"这条底线,同时把最终决策权交还给有能力做判断的人。

回过头看

回到开头的"先读后编辑"的例子。为什么那个约束走不通?

用四个问题重新审视它:

  1. 后果有多严重? → LLM 在没读文件的情况下编辑文件,最多是改错了能恢复。不严重 → 不该硬约束
  2. 模型能自己判断吗? → LLM 天然知道需要先了解文件内容再修改 → 不该硬约束
  3. 误判成本多高? → 拦截了一个正常编辑,只是多花一轮对话 → 不该硬约束
  4. 会过时吗? → 随着模型能力提升,这个约束越来越多余 → 不该硬约束

四条全中"不该硬约束"。它实际上是个软引导问题——当 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,把底线留给框架——这就是"硬约束,软引导"的全部含义。