[控场AI]
· 9 分钟阅读· 4,856 字

别让LLM做决策:构建可预测AI导师的工程实践

别让LLM做决策:构建可预测AI导师的工程实践

AI导师的成败不在提示词,而在把决策交给代码、只让LLM写文案。

文章记录了一位开发者构建SQL导师的工程实践。核心原则是:LLM永远不决定是否帮助、帮助多少,这由确定性纯函数决定,模型只负责把提示措辞写出来。策略被实现为无副作用的policy.js,A组与B组的全部差异仅一行if,所有决策带日志可追溯,并用123个单元测试覆盖。最难的部分是判断什么算一次真实尝试——跑不通的查询没有结果集,需用实质性门槛区分真实思维模型与垃圾输入。防泄漏守卫则在提示到达学习者前做四类确定性检查,101条真实提示零误报,24条泄漏样本拦下22条。作者总结:需要一致性的放进代码,需要针对具体案例的语言交给模型,高代价环节之间插入确定性检查。

给LLM喂进问题、学习者的尝试记录和标准答案,然后告诉它「帮帮学习者,但别太早给答案」——这是做AI导师最容易想到的方案。Demo里跑得通,实验里会失败,生产环境里更会悄无声息地失效。

一位开发者用SQL导师做了对照实验:A组强制先尝试,卡住了才给提示,再不行才给答案;B组像多数AI工具一样,有问必答。6人分组使用后,一周后无导师测试。本文拆解他的工程实现——核心规则只有一句:LLM永远不决定是否帮助、帮助多少,这由普通代码决定;LLM只负责在决策做出后,把提示的措辞写出来。

一张关于AI导师构建的示意图

为什么不能让LLM来做决策

设想提示词版本:「如果学习者看起来卡住了,就给个提示;如果确实努力过了,就给答案。」

然后试着回答一个问题:A组到底收到了什么?

答不上来。「看起来卡住了」是模型每次重新做的判断。两个处境相同的学习者可能得到不同帮助,同一个学习者重试一次也可能不同。你想测试的东西本身成了移动靶。

另一个问题是样本量。6个人的研究里,导师需要决定「给什么帮助」的时刻只有约23次。你没法用23个例子判断模型的决策质量。但你可以完整测试一个纯函数——他写了123个单元测试。

于是系统被拆成三类部件:

部件类型做什么为什么放在这里
人类编写并运行SQL被测量的技能属于学习者
确定性代码先尝试规则、提示/答案规则、答案检查器、防泄漏守卫被测试的东西必须固定且可检查
概率性LLM只写提示文案针对「这个具体错误」的叙述是代码写不出来的

模型只是一个小部件,不是产品本身。

确定性代码与概率性LLM的边界是本文架构的核心。确定性代码指给定相同输入永远产生相同输出的程序,可用单元测试穷举验证;概率性LLM的采样机制使其输出天然带有随机性,同一提示多次生成也可能不同。在实验设计中,这种非确定性会直接污染自变量——如果「是否给提示」由模型判断,那么学习者接受帮助的时机本身就不可复现。因此作者把「决策」与「表达」分离:决策逻辑放进可测试的纯函数,模型只承担无法用规则写死的自然语言生成任务。这种分离并不意味着模型能力弱化,而是把模型的适用范围限定在它真正不可替代的环节——针对具体错误生成个性化叙述。

策略核心:一个纯函数

policy.js 的核心是一段没有DOM、没有网络、不碰LLM的纯函数。它接收一道题的日志,返回一个动作:NONE(已解出)、REFUSE_GATE(A组,尚无有效尝试)、REFUSE_RETRY(A组,已给提示,继续尝试)、HINT(LLM写文案)、REVEAL(给出参考查询)。

const N = item.N ?? 2; // 给提示后再尝试N次才给答案
const counted = countableAttempts(item.attempts);
const helpServed = item.helpServed || [];

if (item.attempts.some(a => a.outcome === 'correct')) return { action: ACTION.NONE };
// A组与B组的全部差异就是这一行
if (item.arm === 'B') return { action: ACTION.REVEAL };
// A组:没有任何帮助,直到有一次尝试被计入
if (counted.length === 0) return { action: ACTION.REFUSE_GATE };
if (helpServed.some(h => h.action === ACTION.REVEAL)) return { action: ACTION.REVEAL };
const hint = helpServed.find(h => h.action === ACTION.HINT);
if (!hint) return { action: ACTION.HINT };
const sinceHint = counted.length - hint.afterCountedAttempts;
return sinceHint >= N ? { action: ACTION.REVEAL } : { action: ACTION.REFUSE_RETRY };

整段代码里唯一值得多看一眼的,是A组与B组之间那一个 if。任何人都能读懂每组到底得到了什么。每个决策都记录 counted 和 sinceHint,所以每次点击帮助按钮,都能从日志证明导师为什么那样响应。

N 对所有人固定为2。如果按人变化,就等于同时测试两件事,而每组3个人根本分不开这两个变量。

什么才算一次尝试

A组的规则是「先尝试」。代码需要判断什么算真实尝试,这成了整个策略里最难的部分。

第一版逻辑是:结果集与上一次不同就算一次尝试。简单,但错了。

Bug在于: 跑不通的查询没有结果集。对于怎么写都执行不了的学习者,没有任何尝试被计入,大门永远不打开。早期测试轮次里,122次尝试中有101次带语法错误、根本跑不起来。这不是边缘情况,这是主要情况。

而一个解析失败的查询,仍可能承载完整但错误的思维模型:

SELECT customer, COUNT(*) FROM orders WHERE COUNT(*) > 3

它跑不起来,却恰好是教学要纠正的典型错误(用 WHERE 而非 HAVING 过滤分组)。它应该被计入。

但 asdf 同样解析失败。如果垃圾输入也算,学习者敲三段乱码就能拿到提示,脑子却什么都没想。教育研究称这种行为为「游戏系统」(gaming the system),学习者很快就能找到这条捷径。

于是他加入实质性门槛:一次尝试必须包含一个SQL关键字,以及本题schema中的一个名称。匹配故意用子串而非严格分词——真实学习者写过 GroupBy 和 havingcount>=3,拼写走样但思维模型是真的,严格token匹配会把它们丢掉。

还有一个 fail open 细节:如果schema列表加载失败,门槛只检查关键字。配置错误的页面绝不能静默拒绝所有学习者,否则A组会在无人察觉的情况下变成「完全不提供帮助」。

用筛选轮的128次尝试验证:121次通过,被拒的7次是6次空提交和1条光秃秃的 SELECT。没有丢失任何真实尝试。

**fail open(故障开放)与 fail closed(故障关闭)**是配置系统常见的两种失败策略。fail closed指依赖项加载失败时默认拒绝服务,fail open指默认放行。文中schema列表加载失败时门槛降级为仅检查关键字,属于fail open:宁可让部分垃圾输入通过,也不能因配置错误让所有学习者卡死在门外。在教育场景中,这种取舍尤其重要——错误地拒绝真实学习者会直接破坏实验组的设计,且往往静默发生、无人察觉。与之相对,防泄漏守卫在提示生成失败时回退到人工兜底提示,也体现了同样的设计哲学:系统的默认故障模式必须朝向「学习者仍能继续学习」,而非「系统安全但不可用」。

防泄漏守卫

不舒服的部分来了:要针对错误查询写出有用提示,LLM必须看到正确查询。他把这个叫参考查询。模型在写提示时手里握着答案,有时它就直接把它印出来了。

如果这行字到达学习者眼前,A组在那道题上就悄悄变成了B组。实验不再测试任何东西,而日志里看不出任何异常。

所以每条提示在到达学习者之前都要过一遍 hint-guard.js。它是确定性的,不调用模型,只看文本说行或不行。四类检查:看起来可运行的查询(SELECT加FROM/JOIN后的真实表名);区分参考查询的子句,除非学习者自己已经写了;参考查询被逐字复现过多;一段「口述答案」——提到四个或更多SQL关键字。

提示被拒就再问模型一次;仍失败则使用该题手写兜底提示。每次尝试和每次拒绝都记录在案。结果:101条真实提示上零误报;针对专门写的24条泄漏样本,拦下22条。

**参考查询(reference query)**在文中指每道练习题的官方标准答案SQL。LLM要针对学习者的错误写出贴切提示,必须理解正确解法是什么,因此提示生成时会把参考查询一并放进上下文。风险在于模型可能直接复述该查询,导致A组学习者未经尝试就拿到答案。守卫的检查逻辑本质上是检测「提示文本与参考查询的信息重叠度」:是否出现可运行的完整查询、是否复现了学习者尚未写过的子句、是否逐字搬运了参考查询、是否用自然语言口述了答案结构。四类检查从粗到细,对应不同粒度的泄漏形态,最终实现零误报与高召回。

每条规则都是被更早的规则逼出来的

守卫里的每条规则,都对应一次更简单规则的失败。

「含SELECT和FROM就是泄漏」 是第一版检查,但这两个词是正常英语。「你写了SELECT,但数据FROM哪里来?」是条好提示,却被拦了。修复方式:可运行的查询必须点出真实表名,参考查询告诉你哪些表存在。

分号规则 同样被删掉——「Nothing is processed; you need to say where the data comes from」被误伤,而表名规则已经覆盖了真实查询。

把学习者的话引用回去 导致一次测试运行中8次拒绝里有7次是误报。提示会说「你的 MAX(price) 没问题」,而 MAX(price) 是答案的一部分,于是被拦。但学习者自己已经写过了,它就在编辑器里。现在只有学习者尚未写过的子句才算泄漏。

没有SQL的泄漏 更隐蔽:「FROM关键字选择products表,然后GROUP BY按category分组……」没有一个子句逐字出现,但你完全可以照着敲出答案。信号是形状而非内容:真提示只谈一两个点,口述答案会点名所有东西。阈值不是凭感觉定的——101条真实提示里,66条没提任何SQL关键字,29条提一个,5条提两个,1条提三个,没有一条提到四个;两条叙述式泄漏分别提到六七个。四这个界限两边都有余量。

静默兜底 是最难发现的一种:LLM藏在Supabase edge function后面,API key不会进浏览器。Supabase免费项目约一周无流量就会暂停,暂停时不报错,A组只是默默提供手写备用提示。应用看起来正常,实际跑的是另一个导师。他找到一次、修复,并加了每次会话前检查的脚本。

这个SQL导师可在GitHub(withholding-pilot)上找到,A组与B组用同一页面、同样16道练习题、同一个判分器,唯一差异是一个配置标志。

对把LLM放进产品的启示

每个决策都问一句:它需要判断力,还是需要每次都一样?

需要可解释、可测试或一致的,放进代码。需要针对「这个具体案例」的语言的,交给模型。模型与用户之间,对任何模型出错代价高昂的环节,插一层确定性检查。

这个导师之所以能说清楚每组到底得到了什么,正是因为这条分界线。也正因如此,实验可以诚实地失败——当两个检查失败时,他知道问题出在学习者身上,而不是模型某个周二心情不同。

系列下一篇:作为开发者跑第一场对照实验时踩的坑,包括一个Demo Day评委当场抓到的问题。

#AI导师#LLM应用架构#确定性代码#AI教育产品#提示词工程#防泄漏守卫#单元测试#SQL学习工具
分享:

相关推荐