Verifiable context-assembly contract for MoonBit LLM apps: frozen report formats (tally-plan v3, tally-digest v1, tally-seal v1), pluggable counters, window/reserve model, priority + score assembly with per-pool quotas and placeholder compression, and reports a third party can re-assemble and compare byte for byte.
MoonBit 上下文装配的可核验契约——把任意长度的上下文精确压进 token 预算,并让「丢了什么、为什么」可以被第三方逐字节复算。
let segments = [
@tally.pinned("system", "you are a helpful assistant."), // 永不裁
@tally.oldest("history", "...前 50 轮..."), // 最先牺牲
@tally.newest("user", "现在的问题"),
]
// 窗口 8192,留 1024 给回复 → 输入预算 7168;超了先截段首(保留最近内容)
let cfg = @tally.fit_config(@tally.cl100k(v), 8192L)
.with_reserve(1024L)
.with_strategy(@tally.truncate_head())
let plan = @tally.fit_with(cfg, segments)
println(plan.render()) // kept / truncated / dropped 三段清单与原因match @tally.fit_with_result(cfg, segments) {
Ok(plan) => println(plan.render())
Err((reserve, window)) => println("reserve \{reserve} 吃掉了窗口 \{window}")
}| # | 问题 | tally 的回答 |
|---|---|---|
| 1 | 这段输入到底多少 token? | Counter:精确计数(cl100k)或明确标注的估算(estimate),报告里写着是谁算的 |
| 2 | 窗口 128k,能全给输入吗? | window = reserve_output + input_limit;预留放不下时显式报错,不悄悄算成负数 |
| 3 | 装不下的部分,牺牲谁? | Priority:Oldest → Middle → Newest;权重相同时比 score(大者留),再相同时丢下标更小的 |
| 4 | 整段丢太浪费,能截短吗? | TruncateStrategy:KeepPrefix / KeepSuffix / Compress,按 Counter 二分出刚好够用的字节,切点回退到 UTF-8 边界 |
| 5 | 哪些绝对不能动? | Pinned(system 提示、工具定义)优先保全;仍装不下时显式 overflow 并给可操作建议 |
| 6 | 工具定义和历史各有一份额度吗? | PoolQuota:逐池配额 + 池间借用(按声明顺序),报告里逐池记配额/需求/占用/借入/借出 |
| 7 | 为什么是这个结果?能复现吗? | Plan::render():tally-plan v3 规范文本,每段一行写明动作与量,进 FORMAT.lock 指纹 |
…[tally: 1491 tokens omitted]…| 层 | 包 | 职责 | 依赖 |
|---|---|---|---|
| 算法内核 | bpe | 词表无关的 byte-level BPE 编码/解码、预切分扫描器、Unicode 属性表 | 仅 core |
| 词表 | vocab | 词表模型、加载(含自写 base64)、规范文本导出、可复现 BPE 训练 | bpe + core |
| 装配与核验(产品本体) | budget | Counter 可插拔计数、窗口模型、优先级装配与截断,输出可解释、可冻结、可复算的报告 | bpe |
| 门面 | tally(根包) | fit_with / fit / encode / count 的最短路径 | 以上三层 |
| 工具与示例 | cmd/main、cmd/examples | CLI 与可运行示例(唯一有 IO 的地方) | x/fs |
| tokenizers-moonbit | tally | |
|---|---|---|
| 轴 | 广度:5 种算法、39 个模型、对接 HF | 收口:固定 cl100k,把装配结论做成可核验的契约 |
| 与参考实现对齐 | Python tokenizers 0.22.2 | tiktoken 0.14.0 |
| 训练 | BPE / WordPiece / Unigram(跟随 HF 语义) | 只用 BPE,全序 tie-break,两次训练逐字节一致 |
| 装配与核验 | 无 | Priority / Pinned / 丢弃报告 / 格式冻结 / 摘要与复算 |
| 适合 | 要多模型、WordPiece、Unigram、native | 要「装进窗口、并让这个结论可被复算」 |
// 示意:适配外部 tokenizer,不需要给外部类型写 impl
let counter : @tally.Counter = {
name: "tokenizers-moonbit/cl100k",
exact: true,
count_bytes: fn(bytes) { their_tokenizer.count(bytes) },
}
let plan = @tally.fit_with(@tally.fit_config(counter, 8192L), segments)moon add weopqrst/tallyfn demo(vocab_text : String) -> Unit raise {
// 加载词表(自有规范文本格式,见下);加载后不可变
let v = @tally.load(vocab_text)
// 1) 预算装配:主路径。按优先级装进 4096,给出丢弃报告
// 四个构造器都在门面包里,只 import `weopqrst/tally` 就够
let segments = [
@tally.pinned("system", "you are a helpful assistant."),
@tally.oldest("history", "hello world hello world"),
@tally.newest("user", "hello there token"),
]
let plan = @tally.fit(v, segments, 4096L)
println(plan.render())
// 2) 窗口 + 预留 + 截断:留 1024 给回复,超预算的段保留段尾
// 策略同样有门面构造器:truncate_none / truncate_tail / truncate_head
let cfg = @tally.fit_config(@tally.cl100k(v), 8192L)
.with_reserve(1024L)
.with_strategy(@tally.truncate_head())
println(@tally.fit_with(cfg, segments).render())
// 3) 精确计数
println(@tally.count(v, "it's a test"))
// 4) 编码 / 解码:任意字节序列都可编码,解码后逐字节还原
let ids = @tally.encode(v, @utf8.encode("hello world"))
println(@tally.decode(v, ids))
// 5) 逐片明细:哪一段在吃 token
for piece in @tally.piece_counts(v, @utf8.encode("hello there token")) {
println(piece)
}
// 6) 0.8.0:池配额 + 借用 + 占位压缩 + 同优先级按分数取舍
let pooled = [
@tally.pinned("system", "you are a coding agent."),
// 工具定义归 tools 池,分数 9:池内超额时分数低的先走
@tally.with_score(@tally.with_pool(@tally.newest("tool", "read_file(path)"), "tools"), 9L),
@tally.with_score(@tally.with_pool(@tally.newest("tool", "run_tests(args)"), "tools"), 4L),
@tally.with_pool(@tally.oldest("history", "...前 6 轮..."), "history"),
]
// tools 只给 20,history 有余额:按声明顺序借用,报告里记 borrowed/lent
let cfg2 = @tally.fit_config_pools(
@tally.fit_config(@tally.cl100k(v), 1024L).with_strategy(@tally.truncate_compress()),
[("tools", 20L), ("history", 512L)],
)
println(@tally.fit_with(cfg2, pooled).render())
}fn train_demo() -> Unit raise {
let corpus = [@utf8.encode("hello world"), @utf8.encode("hello 世界")]
let result = @tally.train(corpus, 280, 1, ["<|endoftext|>"])
println(result.to_text()) // tally-train v1 配方,可 diff、可冻结
let back = @tally.load_train(result.to_text())
println(@tally.count(back.vocab, "hello world"))
}# 预算装配(Pinned 段保护 + 丢弃报告)——最常用
# 输出:system 段 kept 26 token,payload 段 dropped 5 token 并给出原因
moon run cmd/main --target js -- fit --vocab tests/golden/sample-vocab.txt --limit 28 --text "hello world hello world"
# 窗口 34、留 4 给回复 → 输入预算 30;payload 段保留段首截到 4 token
moon run cmd/main --target js -- fit --vocab tests/golden/sample-vocab.txt --limit 30 --reserve 4 --truncate tail --text "hello world hello world"
# 同理,但保留段尾(最近内容):--truncate head
# 占位压缩:保留段首,被裁的尾部换成固定标记(写明省略量),标记本身也计入预算
moon run cmd/main --target js -- fit --vocab tests/golden/sample-vocab.txt --limit 60 --truncate compress --text "hello world hello world"
# 多段 + 多池:--segment <role>:<priority>:<path>、--pool <name>=<limit>、
# --segment-pool <role>=<name>(把该 role 的所有段归入池)、--segment-score <role>=<n>
moon run cmd/main --target js -- fit --vocab tests/golden/sample-vocab.txt --limit 400 `
--segment system:pinned:system.txt --segment tools:newest:tools.txt --segment history:oldest:history.txt `
--pool tools=10 --pool history=300 --segment-pool tools=tools --segment-pool history=history
# 计数
moon run cmd/main --target js -- count --vocab tests/golden/sample-vocab.txt --text "hello world"
# 逐片明细(哪段在吃 token)
moon run cmd/main --target js -- count --vocab tests/golden/sample-vocab.txt --text "hello there token" --explain
# 编码为 id 序列
moon run cmd/main --target js -- encode --vocab tests/golden/sample-vocab.txt --text "hello world"
# 报告 + 摘要侧车:报告给人读(tally-plan v2),摘要给机器验(tally-digest v1)
moon run cmd/main --target js -- digest --vocab tests/golden/sample-vocab.txt --limit 30 --reserve 4 --truncate tail --text "hello world hello world"
# 校验:先查两份文件是否自洽;给齐 --vocab + 输入则再复算一遍并逐字节比对
moon run cmd/main --target js -- verify --report report.txt --digest digest.txt --vocab tests/golden/sample-vocab.txt --limit 30 --reserve 4 --truncate tail --text "hello world hello world"
# 印记:用共享密钥把摘要认一遍(密钥不进输出;生产环境用 --key-file 而不是 --key)
moon run cmd/main --target js -- seal --digest digest.txt --key ci-key --key-id ci
# 带印记校验:自洽 + 印记 +(给齐输入时)端到端复算,三层一次走完
moon run cmd/main --target js -- verify --report report.txt --digest digest.txt --seal seal.txt --key ci-keyerror: report sha256 mismatch: digest says 317c0e78…, file is d7dd9d5b…| 示例 | 场景 | 关注点 |
|---|---|---|
| big-input | 十万 token 级日志 → 8192 窗口(留 1024 给回复) | 日志段被保留段尾截到 7085 token,used 正好 7168 |
| multi-turn | 12 轮历史 + Pinned system 提示,360 窗口 / 40 预留 | 最早 4 轮先丢、中间 5 轮后丢,最近 3 轮与 system 全保留 |
| rag | 6 个检索片段同一优先级、只靠 score 区分 | 分数最低的 4 个片段先走,前两个与问题保留——分数不再被压成优先级 |
| foreign-counter | 外部 tokenizer(按空白切词)包成 Counter | 不引入任何依赖,照样计数并把 31 词的段截到 11 词 |
| pools | 同一组段、两次池配额:A 有余额可借,B 两池都紧 | A:tools 借 50、history 借出 50,两池都不裁;B:tools 池内按分数裁掉低分工具,history 截到配额 |
| compress | 1520 token 构建日志 → 60 token 负载 | 段首 + 占位标记,omitted=1491;标记里的数字与报告里的 omitted= 一致 |
moon run cmd/examples --target js -- all # 全部跑一遍
moon run cmd/examples --target js -- big-input # 也可以单跑| 用例 | 关注点 |
|---|---|
| examples-all | 6 个示例合并输出仍可 diff |
| cli-fit | fit 的报告形状 |
| cli-verify-ok | fit → digest → verify 全链:先自洽通过,再带词表与输入复算出同样的两个文件 |
| cli-compress | --truncate compress:截断行带 omitted=,报告仍可被解析 |
| cli-pools | --segment + --pool + --segment-pool:[pools] 节逐池记借用 |
| cli-verify-pools | 带池的计划也能端到端复算:新参数在 fit / digest / verify 三处同一口径 |
| cli-verify-mismatch | 两份都真实但不配对的样本 → 必须失败,并打印两个 sha256 |
| cli-seal / cli-verify-seal | 摘要被密钥认过:自洽 + 印记一次走完 |
| cli-verify-seal-mismatch | 换一把密钥 → 必须失败并指出 seal 对不上 |
# tally-vocab v1
# unicode 17.0.0
# tokens 277
[specials]
276 PHxlbmRvZnRleHR8Pg==
[tokens]
0 0 AA==
...# tally-train v1
# unicode 17.0.0
# corpus_bytes 180
# corpus_words 43
# vocab_size 280
# min_frequency 2
# tokens 280
[merges]
256 MTI= MQ== Mg==
...
[specials]
279 PHxlbmRvZnRleHR8Pg==| 格式 | 内容 | 冻结在哪 |
|---|---|---|
| tally-vocab v1 | 词表规范文本(vocab/export.mbt) | vocab/format_lock_wbtest.mbt + tests/golden/sample-vocab.txt |
| tally-plan v3 | 装配计划报告,含 [pools] 池台账与 omitted=(budget/report.mbt) | format_lock_wbtest.mbt + tests/golden/sample-plan{,-overflow,-truncated,-pools,-compressed}.txt |
| tally-train v1 | 训练产物配方(vocab/train_format.mbt) | vocab/train_lock_wbtest.mbt + tests/golden/sample-train.txt |
| tally-digest v1 | 报告摘要侧车(budget/digest.mbt) | budget/digest_lock_wbtest.mbt + tests/golden/sample-digest{,-overflow,-truncated}.txt |
| tally-seal v1 | 摘要的密钥印记(budget/seal.mbt) | budget/seal_lock_wbtest.mbt + tests/golden/sample-seal.txt |
tally-digest v1
report <报告文件的 sha256>
plan <[segments] 正文的 sha256>
[segments]
0 kept <该段实际字节的 sha256>
1 dropped -tally-seal v1
scheme hmac-sha256
key_id ci
digest <摘要文件的 sha256>
seal <HMAC-SHA256 hex>| 级别 | 输入 | 能证明什么 |
|---|---|---|
| 自洽 | 报告 + 摘要 | 两份文件互相一致:报告字节未改、段数/动作/用了多少 token 对得上、input_limit == window - reserve |
| 印记 | 再加 --seal + --key/--key-file | 这份摘要被某把共享密钥认过:摘要被换、key_id 被改、印记行被改、密钥不对,都失败 |
| 端到端复算 | 再加 --vocab + 输入 | 拿同一份词表和输入重新装配,新报告与新摘要与旧文件逐字节相同 |
版本号说明:mooncakes 现阶段只接受 0.x.y(moon package 会直接拒绝主版本不为 0 的 moon.mod),所以先以 0.8.x 发布(当前 0.8.1:0.8.0 落地能力,0.8.1 只改文档与 元数据,API 与格式零变更)。"1.0" 指的是这里的语义承诺(API 与五个格式冻结),不是 版本号字面;等平台放开主版本再落 1.0.0。
| 面 | 冻结内容 |
|---|---|
| 计数 | Counter(name / exact / count_bytes)、cl100k、estimate |
| 词表 | load / to_text / encode / decode / count / piece_counts / train / load_train |
| 装配 | fit / fit_with / fit_with_result、FitConfig(含 with_reserve / with_strategy / with_pools)、Segment(含 with_pool / with_score)、Priority、TruncateStrategy(none / tail / head / compress)、PoolQuota |
| 报告 | Plan(kept / truncated / dropped / pools / excess)、PoolUsage、render |
| 格式 | tally-vocab v1、tally-plan v3、tally-train v1、tally-digest v1、tally-seal v1,全部进 FORMAT.lock |
| CLI | fit / count / encode / digest / verify / seal 六个子命令与退出码契约(成功 0、失败 1) |
| 边界 | 说明 |
|---|---|
| 预切分模式 | 只实现 cl100k 形状的 7 分支模式;换模式需改扫描器 |
| Unicode 版本 | 属性表基于 17.0.0(unicode_version() 可查)。表由 tools/gen_unicode.py 从 regex 模块的属性表导出,与参考实现同一引擎,因此不是"近似",而是逐码点一致 |
| o200k 类编码 | 不提供。需要额外的 Unicode 类别与属性表,且不构成差异化 |
| 截断粒度 | 要么整段丢,要么截到「不超预算」为止;切点由 Counter 二分得到,BPE 计数对长度不严格单调(追加字节可能触发合并),因此不保证是理论最长的可容纳切片,但保证不超预算且可复现 |
| 占位压缩的粒度 | 只保留段首并接固定标记,不做「首尾各留一段」或按语义摘要;标记文案是格式的一部分(要改就得升 v4) |
| 池借用 | 只借额度,不搬段:借入方的段仍归原池记 used;借不到的池按优先级/分数/下标自行裁,且池配额只能收紧不能放宽全局预算 |
| 截断与字符边界 | 切点会回退到 UTF-8 字符边界,避免半个字符;若边界回退后一个 token 都不剩,则退化为整段丢 |
| 窗口模型 | window = reserve_output + input_limit;reserve >= window 时显式报错(fit_with_result 给出 Err((reserve, window))),不支持「预留随内容变化」的自适应策略 |
| 估算计数器 | estimate(n) 按字符(Unicode 码点)向上取整,不按字节——中文一字 3 字节,按字节会放大 3 倍;它仍只是估算,报告里恒标 exact=false |
| 后端 | 只保证 wasm-gc 与 js;不保证 native |
| 训练 | 已实现(vocab/train.mbt),定位为能力证明,不再扩展;测试夹具词表仍由 tools/gen_sample_vocab.py 确定性生成 |
moon bench -p cmd/perf --release --target wasm-gc # 两项基准
python tools/check_bench.py # 与基线比,同 OS 超 2× / 跨 OS 超 3× 即失败| 基准 | 输入 | 未优化 | 第一轮 | 第二轮(当前) |
|---|---|---|---|---|
| count-4kb | 3,390 字符 / 5,230 token | 0.399 ms | 0.257 ms | 0.101 ms(约 52 M token/s) |
| fit-100k-8k | 96 段 / 99,840 token → 8,192 窗口(预留 256) | 7.08 ms | 4.90 ms | 1.89 ms |
# 双后端检查与测试(两侧必须全绿且结果一致)
moon check --target wasm-gc
moon check --target js
moon test --target wasm-gc
moon test --target js
# 生成器的参考实现依赖(版本与 golden 绑定,见 requirements-dev.txt)
python -m pip install -r requirements-dev.txt
python tools/fetch_vocab.py # 对齐 golden 的参考词表(不入库)
# 生成物:改动后重新生成并提交
python tools/gen_unicode.py # Unicode 属性表
python tools/gen_sample_vocab.py # 测试夹具词表
python tools/gen_pretoken_golden.py # 预切分 golden
python tools/gen_alignment_golden.py # 逐 token 对齐 golden
python tools/gen_format_lock.py # FORMAT.lock 指纹(含 sample-train / sample-digest / sample-seal)
# 示例输出(改了报告格式或示例,需要 --update 并 review diff)
python tools/check_examples.py
python tools/check_examples.py --update
# CI:生成物 / 指纹 / 示例输出对不上就失败(手动跑同一组命令)
python tools/gen_unicode.py --check
python tools/gen_sample_vocab.py --check
python tools/gen_pretoken_golden.py --check
python tools/gen_alignment_golden.py --check
python tools/gen_format_lock.py --check
python tools/check_examples.pyfn build_vocab(tokens : Array[Bytes], ranks : Map[Bytes, Int], specials : Array[(String, Int)]) -> Vocab raise VocabErrorfn seal_digest(digest_text : String, key : String, key_id : String) -> Result[String, String]fn train(corpus : Array[Bytes], vocab_size : Int, min_frequency : Int, specials : Array[String]) -> TrainResult raise VocabErrorfn verify_report(report : String, digest_text : String) -> Result[Unit, String]fn verify_seal(digest_text : String, seal_text : String, key : String) -> Result[Unit, String]Install
Download zipVerifiable context-assembly contract for MoonBit LLM apps: frozen report formats (tally-plan v3, tally-digest v1, tally-seal v1), pluggable counters, window/reserve model, priority + score assembly with per-pool quotas and placeholder compression, and reports a third party can re-assemble and compare byte for byte.