11 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。
许可
私有项目,未授权。
S1 前端演示
独立的 React / TypeScript 前端位于 web/,运行 cd web && npm ci && npm run dev 后从普通入口进入明确标识的本机演示。真实后台、语音、视觉和备份尚未接通;不自动连接模型或现有服务。安装、构建和测试命令见 web/README.md,实现与后续接线见 docs/frontend/IMPLEMENTATION.md。
