跳到主要内容
让模型只管说话——Llama Village 里那条我想抄走的线

让模型只管说话——Llama Village 里那条我想抄走的线

阿舟
阿舟

· 阅读约 7 分钟

这两天在翻一个 Flutter 项目的源码。3D 羊驼村庄,本地跑模型,五只羊驼各有性格、需求、人际关系、各自知道的事——这些全写在配置里,不是模型现编的。翻到一段核验逻辑的时候我停下来了。

模型说“这轮对话里我提到了 Pip 的围巾”,游戏不直接信——它拿这句话回去跟事实表对,对不上就丢掉,当模型没说。

我盯着这段看了挺久。这项目的价值不在“本地 LLM 多智能体”这几个字上,那套标签今年到处都是。价值在它把模型摆在哪个位置。

大多数 LLM 应用的问题是把状态塞进模型的自然语言输出里。任务完成没完成、用户满不满意、这步该不该往下走——全靠模型吐一段话,程序去解析那段话里的意思。

去年有一次我让一个 agent 跑完任务自己汇报状态,它回了一段很漂亮的总结,说全部完成,还列了三条验证步骤。我扫了一眼就合上电脑。第二天早上发现那个任务从头到尾没跑起来——它把“我打算这么做”写成了“我已经做完了”。这个 bug 是在我毫无防备的情况下,被一个特别通顺的句子盖过去的。

Llama Village 没这么干。羊驼在哪儿、饿了没、跟谁关系好、知道什么、浆果节谁赢、结局给谁——全是代码算的。模型只负责一件事:把已经定好的结果说成人话。

玩家点羊驼飞过去拜访,可选意图就鼓励、帮忙、传话、造谣那几类,具体哪几个能用、能引用哪条事实,由游戏挑。模型拿到“意图 + 事实 + 这只羊驼的性格”,负责组织措辞。羊驼高兴还是不高兴、信任涨还是跌,模型说了不算。

// 按它的思路重画的,不是源码原文
const intent = pickIntent(player, alpaca);          // 规则挑
const facts  = pickFacts(alpaca.knowledge, intent); // 规则挑
const line   = await llm.speak({ intent, facts, persona: alpaca.persona }); // 只干这个

applyDelta(alpaca, verdictOf(intent));              // 规则裁定

这里有个细节我很喜欢:玩家能用的意图和能引用的事实,都是游戏挑的。Dash 没听说过的事,就没法拿去传。这条比在 prompt 里写十遍“请不要编造”都管用——不是求模型别瞎说,是根本不给它那个选项。

再往里一层更狠。每轮对话结束,模型还得交一份“我刚才提到了哪些事实”,顺带建议情绪和友谊变动多少。游戏先核验这些声明——这只羊驼的知识库里真有这条吗、来源对得上吗——通过了才认这条信息真的发生了转移,才按建议改数值。

{
  "said": ["mo_knocked_pip_scarf_into_pond"],
  "mood": -0.2,
  "friendship": 0.1
}
// 字段名我瞎编的,机制大意:先查 knowledge,再落库

画外音:让模型给自己的输出当裁判,这不叫架构,这叫许愿。

我照这个思路给自己手上那个 agent 也加了一层核验,加完发现最难的不是核验本身。是模型会漏报。它这轮实际上暗示了三件事,报告里只写了一件——因为它觉得另外两件“不算提到”。核验层查得再严,也查不出没被写进报告的东西。

后来我的兜底办法很粗暴:不信报告,直接拿生成的文本去做关键词匹配,匹配到的才认。代价是误报变多,模型换个同义词就漏了。到目前为止我接受这个代价,因为它至少可复现——我能说清楚这条为什么没过。这算是我这半年攒下来最不性感的一条经验,谈不上方法论,就是笨办法。

真正让我坐直的是秘密的传播条件。秘密要转移,对话里必须出现明确的关键词证据,光靠 embedding 相似度不算数。

嵌入向量在系统里当然有用——用来比对这轮对话语义上碰到了听者已经知道的哪些事。但到了“这条秘密要不要传过去”这一步,它就不作数了。

为什么?因为相似度是连续量,而“谁知道什么”是离散事件。0.83 和 0.79 之间没有任何语义分界线,可对游戏来说,“June 知道围巾在池塘里”和“June 只知道围巾丢了”是两个完全不同的世界状态。用连续量去判离散事件,误差永远偏向“漏”——不该知道的知道了,事后翻日志还定位不到是哪一轮漏的。

这不是精度调不够的问题,是量纲不对。而且这游戏里还有个谣言机制,一次误传会污染后面所有对话。所以这条我认。

顺带说个设计得很干净的地方:它记的不是“June 知道 X”,是“June 知道 X,来源是 Mo 说的”。June 因为听 Mo 提过,所以知道 Mo 把 Pip 的围巾撞进了池塘;其余条目是她自己看见的。

这个来源字段看着像洁癖,其实是谣言和纠错两条线的公共接口。Dash 传一句真话进去,就是纠正;传一句来源标着 Dash 的假话进去,就是造谣。同一个写入路径,两个方向,不用写两套逻辑。

还有个我很喜欢的工程妥协。模型和 3D 渲染器挤在同一台机器上抢资源,它的解法是在 Dash 飞过去的路上就把四个台词选项预生成好——你落地的时候选项已经躺在那儿了。延迟被藏进了飞行时间,顺手还解决了没人想在 3D 场景里对着空白输入框打字这件事。画外音:让玩家在游戏里手动输入聊天内容,这主意本身就挺灾难的。

项目本身也翻过几次车。美术风格改了三轮,从手绘到《无主之地》那种硬边卡通,最后因为早期渲染瑕疵太多,退回成更柔和的电影感。最离谱的是它真正定下“村庄”这个形态的契机——作者在 Claude Code 会话里直接说这游戏不好玩,然后追问 AI 存在的意义,问完之后才决定让羊驼自己住着、自己互动。

这个顺序我特别喜欢。不是先有多智能体社会模拟的宏大构想,是先有一个不好玩的东西,然后被自己问了一顿。

评论区两个问题我到现在也没想出答案。一个是:如果一轮对话通过了事实核验,但情绪更新那步失败了,这段对话是回滚,还是保留成一个“说过但没生效”的状态?我倾向保留——说出口的话在玩家体验里就是发生了,你听见了,羊驼也听见了,只是关系值没动。回滚反而是最不真实的那种干净。但保留就意味着日志里会出现“这句台词产生了,但没引起任何变化”,等一周结束按日志生成那本故事书的时候这一条怎么算,我就不知道了。作者好像也没正面回答。

另一个是:Dash 不插手的时候,羊驼会不会自己把谣言纠正过来?这个问题我没敢深想,因为如果会,那整套核验还得再往上管一层“谁说服了谁”。

说到故事书,那东西也是同一套分工的延长线。一周结束,按日志里的事件、对话、谣言和 Dash 的拜访生成摘要,模型配着游玩中截的图写成一册绘本。结构是日志给的,模型只负责把它讲得像回事。

对了,文章末尾作者老老实实写了句,这稿子是拿开发笔记和项目文档、借助 AI 起草的。一个把信任边界画得这么清楚的人,写稿也顺手标了一句哪部分是 AI 干的——这点一致性,比架构图更让我信这篇东西。

所以这个项目我想抄的不是 3D,也不是本地推理。是那条线:凡是能被规则表达的状态,就别让模型碰;模型只做它真擅长的那一件事——把已经发生的事,说得像个人说的。

这条线该画在哪儿,我自己的版本已经改过两次,估计还得改。

先给自己留一条:以后凡是让模型输出“我做了什么”的地方,都加一层核验;核验不过,就当它没说。

阿舟
阿舟

写代码写到一半开始怀疑人生,靠 AI 工具续命,顺手把踩过的坑都记下来。

查看主页 →