Files

11 KiB
Raw Permalink Blame History

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。 预算耗尽时可以变笨,不能没台词。
  • 危机场景永不降级。 这是产品约束,不是性能优化。
  • 降级要可预测。 单次请求放不下只降一档;到硬阈值才降到下限。

省钱的真正杠杆,按收益排序:

  1. Prompt 缓存 —— 本体/世界法则固定放前缀,命中后便宜一个数量级。杠杆最大。
  2. 上游压缩 —— 小模型先把 20 条记忆压成 3 条,大模型只收结果。输入从 4000 降到 800 tokens。
  3. 批处理 —— 记忆整合攒 30 分钟一次批量处理,不是 20 次单条调用。
  4. 换小模型 —— 收益反而最小,而且伤害人设。

还有一个漂亮的副作用:成本降级可以被叙事化。 大模型不可用时它"有点走神"—— 这既符合人设,又是真的(实现同构的虚构)。


快速开始

# 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、关系账本。

还没有的(按依赖顺序):

  1. core/tick.py —— 四个循环(tick / consolidate / life / reach_out)
  2. memory/retrieval.py —— mood-congruent 检索
  3. memory/consolidation.py —— 整合与身份提升
  4. think/ —— 上下文组装、生成、三道校验
  5. express/ —— TTS 管道、形象、barge-in
  6. api/ —— WebSocket 会话

规格

完整设计见 docs/SPEC.md。

许可

私有项目,未授权。

S1 前端演示

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