Single-file HTML, Canvas 2D, no libraries, no images, no fonts to load.
The one idea worth explaining: the accent colour IS the valence.
moodColor(v) maps v in [-1,1] across cold blue -> neutral grey -> warm amber,
and every mark in the frame reads from it. So when she drops, the whole
picture goes cold -- no caption has to tell you she is sad. Same data,
three places: the trace colour, the memory hue, the retrieval highlight.
Timeline is a pure function render(t), no RAF accumulation. Every frame
renders in isolation, which means frames are reproducible, any resolution
can be re-exported, and the preview can be captured frame-by-frame instead
of screen-recorded.
Four acts:
0-6s membrane matter bounces off it, information passes through
6-12s heartbeat beats with no input; positive recovers fast, negative slow
12-17s memory points sink by salience; one gets pulled back up
by a mood-matching cue; floor at 0.03 so nothing vanishes
17-22s title
Also: ?t=12.5 freezes on that frame (for cover shots), and a REC button
uses canvas.captureStream + MediaRecorder to export webm directly.
Preview: 200 ANMF frames, 22000ms total, verified by parsing the RIFF/ANMF
chunks (Pillow does not expose per-frame WebP duration).
tools/render_preview.py renders frames via headless Chrome and assembles.
On this Windows box Python cannot spawn Chrome (0x80000003, same family as
the 0xC0000142 shell failure), so the render loop was driven from PowerShell
and the script only assembled. Both paths are documented in the script.
10 KiB
CyberGril
一个有自己世界的赛博伴侣。
不是聊天机器人。区别在于:它在你不说话的时候也在运行。
先看一眼
▶ 打开完整动画 — 22 秒,纯代码生成,无任何外部资源
docs/intro.html 单文件动画,双击即播
docs/preview.webp 渲染好的预览(800×450 / 10fps / 22s / 0.5MB)
tools/render_preview.py 重新渲染、换分辨率、导出静帧
动画里有一条贯穿的主线:强调色 = 当前效价 v。
情绪低落时画面整体转冷,而不是靠字幕告诉你"她现在很难过"。
| 幕 | 时间 | 讲什么 |
|---|---|---|
| 膜 | 0–6s | 物质撞在膜上弹回,信息穿过去 |
| 心跳 | 6–12s | 没有输入时它也在跳;难过恢复得慢 |
| 记忆 | 12–17s | 记得的会沉下去,但沉不到底 |
| 名字 | 17–22s | — |
四张静帧:docs/01-membrane.png 02-heartbeat.png 03-memory.png 04-title.png
动画本身的技术取舍:时间轴是纯函数 render(t),不做 RAF 累加。
所以任意一帧都能独立渲染——这也是 render_preview.py 能逐帧截图、
而不需要真实录屏的原因。
它是什么
一个单用户、本地优先、常驻的存在,通过一层"膜"与你通信:
- 有自己的时钟 —— 无输入时仍在演化、仍有生活
- 情绪是动力学系统 —— 慢变量、有惯性、有代价(会心情不好)
- 记忆有形状 —— 三层结构,会遗忘,且遗忘有下限
- 关系是一等实体 —— 信任、债务、未修复的事
- 本体不可谈判 —— 立场不因任何对话、情绪、施压而改变
三层不可混淆的东西
| 层 | 载体 | 变化速度 | 谁能改 |
|---|---|---|---|
| 命 | bazi_core.derived |
几乎不变 | 只有整合器,每次 ±0.02,且留痕 |
| 运 | fortune_period |
按周/月 | 排盘 |
| 状态 | State (v/a/d/e) |
按秒/分 | tick 循环 |
混掉任何两层,角色就会失真:
- 命被对话改 → 人设漂移,变成一个讨好你的东西
- 状态被写成描述 → 模型自己编情绪,动力学失效
架构
contracts/ 数据契约 —— 全项目唯一的共同语言
providers/ 模型接入 —— 按能力建模
core/ 状态核心 —— 独立时钟,不依赖用户输入
memory/ 三层记忆 + 遗忘
dyad/ 关系账本
think/ 模型路由 + 预算 —— 四档分配,按关键时刻
config/ models.yaml(版本号) + character.yaml(世界法则)
设计判断(这一节比代码重要)
1. OpenAI 兼容是 adapter,不是标准
对 LLM 和 Embedding,OpenAI 的形状(/v1/chat/completions、/v1/embeddings)
已经是事实标准,内部契约就直接用它。
但对 ASR / TTS / 形象,OpenAI 的形状会砍掉这个项目最需要的能力:
| 能力 | OpenAI 形状的问题 | 我们的做法 |
|---|---|---|
| ASR | /v1/audio/transcriptions 是「文件进→文本出」,没有 partial;而对话必须要 partial 才能做端点检测 |
内部契约是事件流(partial → final);OpenAI 适配器作为降级档,supports_partial=False |
| TTS | /v1/audio/speech 只有 text/voice/speed,没有副语言标签;而 [laughter] [breath] 是"像活的"的核心 |
内部契约带 tags / emotion;不支持的 provider 在 plan() 里显式降级并上报 |
| 形象 | 没有事实标准 | 自定义 RenderCmd 协议,驱动源必须是 State,不是 LLM 文本 |
关键机制:静默降级是不允许的。
planned = tts.plan(request)
if planned.lost_paralinguistics:
# 日志里会出现 [degraded] speech_tags: ...
...
静默降级会让你在调优时永远找不到"它为什么不像人了"。
2. 状态必须以数值注入,不能用散文描述
✗ "你现在有点难过" → 模型会自己发挥,甚至编出更多情绪
✓ v=-0.62 a=0.41 d=0.30 → 模型知道边界
+ visible="低落"
+ constraint="不得声称与此不符的内在状态"
StateBlock 里那个 constraint 字段不是装饰,它是唯一能阻止
模型绕过状态机的机制(外加输出校验与重写)。
3. 遗忘有下限
mem_episodic.accessibility 的下限是 0.03,不是 0。
- 正常检索拿不到 → 想不起来
- 情绪线索命中时能捞出来 → 但还在
所有现成框架都是"要么记住要么删除",没有这个中间态。
4. 未修复的事变成行为,不变成台词
✗ "你上次伤害了我" → 指责,而且廉价
✓ 主动性 -0.1,回复变短 → 它不说,但它不一样了
5. 缺席可以被提及,不能被使用
✗ "我一直在等你,等了好久" → 制造负罪感,这是操控
✓ "这几天我这边发生了点事" → 它有自己的世界,不是空转等你
这条写在 core/projection.py 的注释里,也有对应的测试。
6. 大模型按「关键时刻」分配,不按时间分配
先纠正一个常见误解:60 秒心跳不该调用任何 LLM。
心跳做的是 s = b + (s-b)*exp(-dt/τ) + I,四个 float 一次 exp,
微秒级。给它配模型是架构错误,不是资源问题。
真正的 token 消耗在两处:每轮对话的输入上下文,以及后台任务的频次。
所以用四档:
| 档 | 用什么 | 场景 | 成本 |
|---|---|---|---|
none |
不调用 | 心跳、主动性判定、填充词、表情驱动 | ≈0 |
low |
本地小模型 | 生活事件、记忆压缩、事实抽取、输出初筛 | 电费 |
standard |
中大模型 + 短上下文 | 投机响应的第二段 | 低 |
high |
旗舰 | 主对话台词、主动联系、危机 | 按 token |
路由判定必须便宜 —— 查表,不调模型。为了省 token 先调一次模型,是本末倒置。
关键约束:
- 主对话的下限是
low,不是none。 预算耗尽时可以变笨,不能没台词。 - 危机场景永不降级。 这是产品约束,不是性能优化。
- 降级要可预测。 单次请求放不下只降一档;到硬阈值才降到下限。
省钱的真正杠杆,按收益排序:
- Prompt 缓存 —— 本体/世界法则固定放前缀,命中后便宜一个数量级。杠杆最大。
- 上游压缩 —— 小模型先把 20 条记忆压成 3 条,大模型只收结果。输入从 4000 降到 800 tokens。
- 批处理 —— 记忆整合攒 30 分钟一次批量处理,不是 20 次单条调用。
- 换小模型 —— 收益反而最小,而且伤害人设。
还有一个漂亮的副作用:成本降级可以被叙事化。 大模型不可用时它"有点走神"—— 这既符合人设,又是真的(实现同构的虚构)。
快速开始
# 1. 安装
pip install -e ".[dev]"
# 2. 配置
cp .env.example .env
# 编辑 .env 填入 API key(不填也能跑,会回退到 echo provider)
# 3. 跑测试
pytest -q
# 4. 检查配置能被正确加载与校验
python -c "from cybergril import load_settings; print(load_settings('config').character.name)"
默认配置不需要任何 API key 就能跑通测试 —— 因为内置了
echo(LLM)/ scripted(ASR)/ silence(TTS)三个离线 provider。
它们的价值是把接口形状固定下来,让上层可以先跑通再换真后端。
换成真模型
只改 config/models.yaml,不动任何代码。
tiers:
high:
provider: openai_compat
model: deepseek-v4.1-flash
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
low:
provider: openai_compat
model: qwen3.5-4b-instruct
base_url: http://127.0.0.1:8000/v1
tts:
provider: cosyvoice # 需要自己实现这个 adapter,见下
model: fun-cosyvoice3-0.5b
新增一个 provider 只需要三步:
# 1. 继承对应的基类
class MyTTS(TTSProvider):
name = "mytts"
# 2. 诚实地声明能力
@property
def capabilities(self) -> Capabilities:
return Capabilities(
modality=Modality.AUDIO,
streaming=Streaming.CHUNKED,
speech_tags=frozenset({"laughter", "breath"}),
)
# 3. 实现 stream()
async def stream(self, request): ...
# 注册
registry.register("tts", "mytts", MyTTS)
能力必须诚实。 夸大能力会让上层做出错误的规划,
而 plan() 会据此剥掉你其实需要的东西。
配置分层
| 文件 | 放什么 | 什么时候改 |
|---|---|---|
config/models.yaml |
唯一允许写模型版本号的地方 | 换模型(每季度复核) |
config/character.yaml |
世界法则、情绪参数、关系参数 | 换人格(几乎不动) |
两个文件互不污染。这是"防止文档和代码一起过时"的机制。
关于版本号的一个约定:只有 models.yaml 允许写版本号。
代码里所有模型引用走配置,不硬编码。
测试
pytest -q # 全部
pytest tests/test_mood.py -v # 情绪动力学
pytest -k degradation -v # 降级机制
测试守的是设计判断,不是实现细节:
| 文件 | 守什么 |
|---|---|
test_contracts.py |
接口形状 |
test_providers.py |
降级必须显式上报 |
test_mood.py |
情绪有惯性;负向恢复更慢 |
test_bazi.py |
命是数值;强弱影响敏感度 |
test_projection.py |
未覆盖事件必须计数;投影不泄漏技术词 |
test_store.py |
遗忘有下限;间隔效应 |
下一步
地基只包含:契约、provider 抽象、配置、情绪动力学、八字编译、投影、存储 schema、关系账本。
还没有的(按依赖顺序):
core/tick.py—— 四个循环(tick / consolidate / life / reach_out)memory/retrieval.py—— mood-congruent 检索memory/consolidation.py—— 整合与身份提升think/—— 上下文组装、生成、三道校验express/—— TTS 管道、形象、barge-inapi/—— WebSocket 会话
规格
完整设计见 docs/SPEC.md。
许可
私有项目,未授权。
