Beyond chat with llamadart · 第 2 部分
Llama Village,五只羊驼共享一个世界,但并不共享它们所知道的一切。
Pip 的红围巾不见了。Mo 知道它在哪儿。他还知道它是怎么到那儿的——而且宁愿把那部分留给自己。
这给村子带来了一桩小小的谜案。它给游戏带来一个更难的问题:当两只羊驼交谈时,听者究竟应该了解到什么?
我在Llama Village:一个由本地 AI 驱动、基于 llamadart 的虚拟世界中介绍了这款游戏。在这里,我们将追踪其机制的一部分:本地嵌入帮助把一只羊驼说的话与可能已被分享的事实进行比较。
终点是一处你可以检查的变化:一只羊驼获得了信息,游戏记录了是谁告诉它的,而它仍可能对此心存怀疑。对话、嵌入和游戏规则各自承担不同的职责。
一个秘密始于一只羊驼
游戏把围巾的位置和 Mo 的意外分别存为独立的事实。一开始,Mo 两者都知道。知道围巾在哪儿,并不自动意味着知道是谁把它撞进了池塘。
在检查一段对话之前,游戏会缩小可能性范围:说话者知道而听者不知道的事实。它把每个事实与那位说话者的台词逐一比较,而不是与两个角色的所有台词比较。
嵌入模型已从训练数据中学到了文本表示。它把每句台词和每个事实映射为一个数值向量;余弦相似度则比较这些向量。相似的措辞和相关的含义可能让向量更接近,但该分数并不是判断含义是否相同、是否为真、或某句话是否蕴含某个事实的是/否测试。游戏采用每个事实在说话者各句台词中的最高相似度。
AI 生成的概述:比较含义、核查证据,然后记录是谁说的。这是一幅概念示意图,并非游戏运行的实际记录。
该实现通过 llamadart 使用 EmbeddingGemma,并按文本缓存向量。它对title: none | text:台词和事实都加以应用。本演练复现了游戏中的这一选择。Google 的模型卡建议为新应用使用针对具体任务的提示前缀,其中包括用于句子相似度的查询提示;更改格式后需要重新评估分数和阈值。
字词不必完全一致
June 和 Bramble 在录制的游玩过程中谈论诗歌。这展示的是对话系统,而不是下方图示的围巾交换。
作为一个小型演示,将存储的事实“Pip 的红围巾躺在池塘的芦苇丛中”与示例句子“我看到 Pip 的围巾缠在池塘边的芦苇丛里”进行比较。它们用不同的词语描述了相关的信息。这句话是为本说明而写的,并非引自录音。
输入是文本。嵌入输出是一个数值向量。然后应用程序计算相似度分数;模型并不会返回“June 得知了围巾的位置”。那个判断是之后才做出的。
对两段文本使用相同的嵌入模型和兼容的设置。仅向量长度相等并不能保证表示兼容。
通过 llamadart 跟进这段文本
加载合适的嵌入引擎后,核心调用如下所示。完整的加载和比较示例在末尾。
final facts = [
"Pip 的红围巾躺在池塘的芦苇丛中。",
];
final factVectors = await engine。embedBatch(
facts。map((text) => 'title: none | text:$text')。toList(),
normalize: true,
);
final lineVector = await engine。embed(
"title: none | text: 我看到 Pip 的围巾缠在
池塘边的芦苇丛里。",
normalize: true,
);
游戏配置的嵌入产物是embeddinggemma-300M-Q8_0.gguf。对于其他模型,请遵循其输入格式要求。llamadart 嵌入指南涵盖了加载、后端支持和输入限制。
对于归一化向量,点积即为余弦相似度。在游戏中,关键词证据会带来 0.3 的排名加分,最佳六个候选会送入结果模型。这些是游戏特有的选择,并非标准的嵌入阈值。
我在 macOS arm64 上使用已发布的llamadart 0.11.0和本地embeddinggemma-300M-Q8_0.gguf产物运行了配套示例。它返回了 768 维向量。以下是该次运行中选出的余弦相似度;这些是通过真实模型运行的示例输入,并非游戏过程中捕获的对话。
| 输入行 | 与之比较 | 余弦相似度 |
|---|---|---|
| 我看到 Pip 的围巾缠在池塘边的芦苇丛里。 | 围巾位置 | 0.8449 |
| 我没有把围巾撞进池塘。 | Mo 的事故 | 0.6085 |
| 今天天空看起来很美。 | 围巾位置 | 0.3137 |
否认是揭示性案例:尽管它说的是相反的话,它仍然与事故事实相似。这些分数属于这个工件和格式;它们不是通用的截断值或概率。
谁来决定意义是否匹配
嵌入不会先检测主题,然后再证明意义匹配。它们产生一个表示和一个相似度分数。要问“这个说话者是否真的揭示了那个事实?”,Llama Village 会把对话和入围事实发送给一个单独的生成式结果模型。该模型被提示返回那些内容被明确说出口的 ID。
说明性替代方案,不是记录下来的交流:提到同一条围巾和同一个池塘并不会让否认变成坦白。AI 生成的插图;不暗示任何相似度分数。
对于说明性的否认“我没有把围巾撞进池塘里”,预期的解释是事故没有被坦白。结果模型必须解释措辞和上下文才能得出那个结论;它可能出错。它的 JSON 约束响应的形状和允许的 ID,而不是解释是否正确。
然后代码将模型提出的揭示与关键词和相似度结合起来。位置秘密需要“围巾”和一个位置词。事故秘密需要“围巾”和一个责任词,比如“撞”。关键在于,“我没有把围巾撞进池塘里”仍然匹配那些子串组。它们不理解否定。
当前游戏规则存在一个真正的弱点。对于一个秘密,匹配关键词加上相似度 ≥ 0.5 可以接受一次转移,即使结果模型没有声称它。将测得的否认分数 0.6085 输入游戏实际的keywordHit和acceptTransfer函数返回了accepted=true带有claimed=false。这是规则层面的复现,不是完整的游戏运行:事实仍然必须符合条件并进入入围名单。
因此,当前规则不能保证意义匹配。更严格的设计会移除针对秘密的仅相似度回退,并要求带有支持性对话的明确揭示判断。
一个可能的下一步是 llamadart 的DecisionEngine,与 Laya 一起在本地 AI 模型能学会玩俄罗斯方块吗?在这里,选择将围绕 Mo 的话展开:给定对话和一个候选事实,他是否会透露它、否认它,还是让它保持不明确?
嵌入向量筛选 → DecisionEngine 判定 → 游戏应用规则。
只有“已透露”的判定才会让该事实通过游戏的知识传递检查;仅凭相似度将不再被接受。这是一个提议中的扩展,并非当前实现。结构化答案仍可能是错的:我们需要在否认、推测、引用和模糊对话上评估 Laya——并可能需要微调一个专用头——才能声称它优于当前的结果模型。
| 当前的秘密传递检查 | 所需证据 |
|---|---|
| 关键词 | 每个关键词组都匹配 |
| 额外支持 | 结果模型声称它或相似度 ≥ 0.5 |
| 已知弱点 | 相似度分支可以绕过未经声称的透露 |
June 听到了这个故事,但仍然怀疑它
看看 June 的检查器:Mo 的围巾事故被标记为“从 Mo 处听说;可能不真实”。截图显示的是一个已存储的结果,而不是捕获的嵌入分数,也不是产生该结果的精确对话的证明。
在接受一次传递后,游戏会记录讲述者,并将“听说过信息”与“相信信息”区分开来。相信取决于讲述者的信念和听者的信任规则。这种区分让一段对话能够改变村庄,而不必让每个听者都接受每个故事。
给你的应用同样的区分
可复用的模式是:用应用状态缩小候选范围,用嵌入向量找到可能的匹配,并对有后果的状态变更给出单独评估的判定。Llama Village 展示了这一模式以及一种失败模式:相似度回退可能会抹掉“找到相关文本”与“接受一次透露”之间的区分。
将来源和出处与向量一起保留。当模型或其输入格式发生变化时,重建缓存的向量。选择适合运行时的嵌入模型,并在加载后检查后端支持。
Pip 丢失的围巾让这个技术问题变得可见:是有人说了它在哪里、承认了它是怎么到那里的、否认了它,还是只是提到了池塘?要分别测试这些情况。一段令人信服的对话和一次正确的状态更新是两件需要分别评估的事。
关于世界其他地方,包括 Dash、浆果节和故事书式的结局,请参见Llama Village 文章。本系列的下一篇:本地语音。
完整的嵌入比较示例
本示例使用 Dart 3.13.5 进行分析和执行,并发布于llamadart 0.11.0macOS arm64。创建一个 Dart 控制台项目,将llamadart: 0.11.0固定在其pubspec.yaml中,运行dart pub get,并将代码保存为bin/village_embeddings.dart。使用本地 EmbeddingGemma Q8_0 GGUF 并运行下面的命令。这两个前缀有意复现了游戏的格式。这里比较的是五条固定事实;它并未实现角色资格、结果模型或知识更新。
dart run bin/village_embeddings.dart \
/absolute/path/to/embeddinggemma-300M-Q8_0.gguf \
'title: none | text: ' 'title: none | text: '
import 'package:llamadart/llamadart.dart';
Future<void> main(List<String> args) async {
if (args.length != 3) {
throw ArgumentError(
'Usage: dart run village_embeddings.dart model.gguf '
"query prefix" "document prefix" (use "" for no prefix)',
);
}
const facts = [
'Pip\'s red scarf is lying in the pond reeds.',
'Mo knocked Pip\'s red scarf into the pond reeds by accident and never told her.',
'Bramble writes anonymous love poems to June.',
'Pip\'s red scarf has gone missing from her hut.',
'June made up the rumour that Mo\'s bread made Bramble sick.',
];
final engine = await LlamaEngine.load(
LlamaModel(ModelSource.path(args[0])),
);
try {
if (!(await engine.capabilities).supportsEmbeddings) {
throw StateError('The active backend does not support embeddings.');
}
// Index these once; reuse them for subsequent searches.
final vectors = await engine.embedBatch(
facts.map((text) => '${args[2]}$text').toList(),
normalize: true,
);
for (final query in [
'I saw Pip\'s scarf tangled in the reeds by the pond.',
'I did not knock the scarf into the pond.',
'The sky looks beautiful today.',
]) {
final vector = await engine.embed(
'${args[1]}$query',
normalize: true,
);
final ranked = [
for (var i = 0; i < facts.length; i++)
(text: facts[i], score: dot(vector, vectors[i])),
]..sort((a ,b )= >b.score.compareTo(a.score
));print('\n$query—${vector.length}dimensions'
); for( final hit inranked.take(3 {
print('${hit.score.toStringAsFixed(4)}${hit.text}');
}
}
} finally {
await engine.dispose();
}
}
double dot(List<double> a, List<double> b) {
if (a.length != b.length) {
throw ArgumentError('向量维度不一致。');
}
var result = 0.0;
for (var i = 0; i < a.length; i++) {
result += a[i] * b[i];
}
return result;
}




