我最近写了一个状态化角色对话引擎 roleplay-engine,跑在 FastAPI 上,底子是 Markdown 文件加一个 JSON 对话日志。这篇不讲它有多少功能,只讲两个我觉得值得拿出来反复看的设计取舍:一是把存储格式、prompt 格式、维护格式三者压成同一种东西,二是在事件循环里安全地拉起一个会跑几分钟的 AI 子进程。后者听上去朴素,实际坑很密,我把它逐条记下来,是希望后来者别再踩一遍。

目标读者是做 AI 工程、尤其是想搭「有记忆、有状态」的对话系统的人。如果你正在纠结要不要上向量库、要不要用 LangChain 之类的编排框架、要不要给角色建模专门设计一套数据库 schema,那么这篇文章大概能给你一个反面参考——很多复杂度是自找的,砍掉之后世界并不会塌。仓库在这里:git.ruochongliang.top/lrc/roleplay-engine

核心洞察:三合一,零转换

这个项目最核心的一句话写在 README 第一行:存储格式 = prompt 格式 = 维护格式,三者合一,零转换。角色、世界、场景、系统提示词,全部是 Markdown 文件,YAML 头加自然语言正文。这个决定不是后来补的,是立项时就锁死的,它顺势决定了后面所有模块的形状。

为什么这件事重要?因为传统做法里这三者是分裂的:角色数据存在数据库或 JSON 里,运行时再有一套模板把它渲染成 prompt,维护时人又得对着数据库表或专门的编辑器改字段。三套格式意味着三次心智对齐、三处可能漂移的真相。一旦渲染模板改了而 schema 没跟上,或者数据库里塞进了 prompt 模板渲染不了的内容,调试起来非常痛苦。我把这三种格式压成同一种 MD 之后,改档案就是改 prompt 就是改数据库,编辑器直接打开就能维护,没有任何转换层。

代价当然有。最大的代价是结构化查询能力几乎为零——我不能用 SQL 问「所有标签含”乐观派”的角色」,只能 grep。但对于一个单人玩具规模的角色对话引擎,这个代价完全可接受。我用一张表把几条路径摆出来对比:

方案代价为何不选
数据库存字段 + 模板渲染 prompt三套格式要对齐,模板和 schema 漂移是常态;调试时要同时看三处心智负担太重,单人维护不动
纯 JSON 存档 + 代码拼 prompt维护时要对着 JSON 改,可读性差;人写不自然人不可读,维护体验糟糕
MD 三合一(本项目)失去结构化查询;YAML 头需要轻量转前缀单人规模下 grep 够用,转换层就一个函数

YAML 头的处理是三合一里唯一一处需要「转换」的地方,但也极轻量。它只是把结构化元数据转成一行自然语言前缀,让模型更舒服地知道接下来这段是谁的信息:

def yaml_front_to_prefix(front: dict, label: str) -> str:
    # [角色档案]
    # 姓名:小明 / 代号:xiaoming / 标签:童年好友, 乐观派

MD 正文则是整体保留,不重排,不转换,直接拼进 system prompt。这意味着我写档案时怎么组织段落,模型就怎么看到段落,所见即所得。这个「正文零转换」是三合一的真正含义——YAML 头那个前缀只是锦上添花,正文才是大头,正文不动,转换成本就趋近于零。

三合一的本质

不是「用 MD 存数据」,而是「让维护者写下的每一个字,都原样进入模型上下文」。人、模型、维护者看到的是同一份文本,这才是零转换的真正含义。

运行时极简:读、拼、调

运行时热路径我刻意压到最短。每轮对话就是六步:读四个 MD → 拆 YAML 头 → 宏替换 → 按固定顺序拼 system prompt → 取最近 N 轮历史 → 调模型流式返回。没有向量库、没有调度器、没有状态机、没有数据库,零外部依赖(除 LLM API 本身)。这个极简不是偷懒,是判断——技术路径越短,能出问题的地方越少,确定性越高

拼装顺序是固定的:framework(一句话确立身份)→ 角色档案 → 世界设定 → 用户角色 → 其他参与者 → 当前场景 → 历史。角色紧接 framework 放最前是为了强化身份锚定,历史放最后是因为最近的东西最重要。这个顺序的每一个位置我都想过,不是随便排的,但实现起来就是 "\n\n---\n\n".join(parts) 一行。设计考究,但实现极简,这是我对”简单问题优雅化”的理解。

prompt 编排里有两个细节值得单独说。第一个是对外展示信息的隐私边界:拼装 prompt 时,当前发言者拿到自己的完整档案,但其他参与者只注入其「对外展示信息」段落(外貌、身份、第一印象这些可观察的东西),绝不把对方的记忆、内心想法、私下人际关系泄漏给当前发言者。否则模型会”开天眼”——明明 A 不可能知道 B 的内心活动,却写出来了。这个细节看着小,实际是角色扮演沉浸感的关键。

第二个是 token 预算保护history_turns(默认 20 轮)加 max_context_tokens(默认 8000)双约束,超限时从最旧历史丢弃。但这里有个反直觉的设计:场景切换锚点受保护,即便超预算也不丢。原因是如果一段历史横跨了场景切换,把切换点前后的消息丢了,模型就理解不了”刚才在酒馆,现在怎么到森林了”。我用 tiktoken 精确估算,退化时按字符数兜底,宁可略微超预算也要保锚点。

快慢分离:pi 离线重建长期状态

角色对话引擎绕不开一个问题:角色要不要随着对话成长。我见过的多数方案是在线成长——每轮对话后更新一些状态向量、记忆条目、关系图谱。我的判断相反:对话时不成长,攒够原始对话日志后,手动跑一次 pi 智能体离线重建角色档案

这个取舍的核心是把快慢两条路径分开。热路径(对话)要求快响应,几百毫秒内返回第一个 token,所以它只做读、拼、调,不做任何写档案的操作。慢路径(重建)要求深度思考,pi 要把几十轮对话读一遍,分析角色的成长轨迹,重写整个 MD,可能跑几分钟。把这两件事放在同一条路径上,要么牺牲响应速度,要么牺牲沉淀质量。我选择彻底分开:热路径只追加对话日志(JSON,真相源),慢路径定期扫日志重写档案。

重建流程也很朴素:选定角色 → 扫 conversations/ 下所有含该角色的 JSON → 把对话日志加当前角色 MD 加重建指令一起喂给 pi → pi 用 write 工具直接写回 characters/{code}.md服务端不解析 pi 的输出,pi 写完文件就在了,下次对话自然就用上新档案。这个”直接写回”的决策来自架构文档 §10.3,理由是服务端不需要承担”理解 pi 输出”的责任,契约靠 prompts/rebuild.md 约定(YAML 头字段、五个二级标题结构保持),出问题就退回 git。

快慢分离是设计原则,不是性能优化

在线成长不是”做不到”,而是”不该做”。把慢思考从快响应里剥离出来,是 LLM 应用走向稳定的关键一步。任何想让一个进程同时做好这两件事的尝试,最终都会被其中一边拖垮。

pi 子进程调用的六个工程坑

pi 重建的实现全部在 pi_rebuild.py,文件头有大段注释,我把那段注释当作现成的工程备忘录。这里把它展开成六个具体的坑,每一个都是真实踩过的。我的判断是,凡是让事件循环去拉起一个长跑子进程的代码,都得过这六关

坑一:原子锁必须用 O_EXCL,不能 check-then-set

同一角色同时只允许一个重建进程,否则两个 pi 同时写同一个 MD 会互相覆盖。最直觉的写法是先 os.path.exists(lock) 检查,不存在再创建——这是经典的 TOCTOU(time-of-check to time-of-use)漏洞,两个并发请求都可能看到”不存在”然后同时创建。正确做法是用 os.open(O_CREAT | O_EXCL)由内核保证原子性:文件已存在则 open 失败抛 FileExistsError,没有竞态窗口。

flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
try:
    fd = os.open(lock, flags, 0o644)
except FileExistsError:
    raise RebuildInProgressError(character)

锁文件内容写 pid,方便 stale 清理和冲突诊断。stale 锁的清理统一放服务启动时做(cleanup_stale_rebuild_locksos.kill(pid, 0) 探活),运行时不做竞态探活——运行时的正确性完全靠 O_EXCL 保证,不靠”我觉得这个锁是 stale 的”。

坑二:文件名要防 argument injection

find_conversations 扫描对话日志时,会把匹配的文件路径作为位置参数传给 pi。如果文件名以 - 开头,比如(恶意或异常的)-output.txt.json,传给命令行时会被 pi 解释成 flag。过滤任何以 - 开头的文件名是基本防护,每个返回路径还要做 realpath 校验仍在目录内(防符号链接逃逸)。

if name.startswith("-"):
    logger.warning("S9: find_conversations 跳过 `-` 开头文件名: %s/%s", conv_dir, name)
    continue

这条规则听着像过度防御,但 argument injection 是真实攻击面——只要文件名来自文件系统扫描且会进入命令行,就得防。别指望调用方传的参数永远干净

坑三:stdout/stderr 直写文件,绝不走 PIPE

最初的设计图(架构文档 §10.2)写的是 stdout=asyncio.subprocess.PIPE,事后读出来。这在 pi 输出少的时候能跑,但 pi 是个编码 agent,会吐大量日志和中间过程。PIPE 缓冲区是有限的(通常 64KB),满了之后子进程的 write 会阻塞,而父进程如果没及时 read,子进程就 hang 死。这个 hang 极难诊断,因为没有任何报错,进程就是不动。

正确做法是把一个打开的文件句柄直接传给 create_subprocess_exec 的 stdout/stderr,让 OS 直接写文件,完全不经过 PIPE:

log_fh = open(log_path, "w", encoding="utf-8")
proc = await asyncio.create_subprocess_exec(*cmd, stdout=log_fh, stderr=log_fh)

文件句柄交给子进程和后台监控 task 关闭。日志路径要经安全校验(character 已 validate_safe_name,pid 纯数字),commonpath 校验仍在 logs/ 内。

坑四:永远不用 shell=True

shell=True 意味着参数会经过一层 shell 解析,引号、分号、变量替换全都是注入面。create_subprocess_exec 接受列表传参,每个参数都是独立 argv 项,不经 shell,从根本上消除了 shell 注入。这条几乎是 Python 子进程的常识,但值得反复强调,因为很多教程和 stackoverflow 答案为了”简洁”会用 shell=True。简洁不能换安全。

坑五:不用 subprocess.run,必须用 asyncio 的版本

FastAPI 是 async 的,事件循环单线程串行处理所有请求。subprocess.run 是同步阻塞调用,它会卡住整个事件循环——不只是当前请求,所有正在处理的请求都会停摆,直到 pi 子进程退出(几分钟)。这是 async 应用里最致命的一类错误。必须用 asyncio.create_subprocess_exec,它把子进程的 IO 注册到事件循环,等待时不阻塞其他协程。

子进程启动后,立即返回 pid 给调用方,不等 pi 跑完。用一个后台监控 task(asyncio.create_task(_monitor_rebuild(...))await proc.wait(),退出后清锁、关日志句柄。这里有个隐蔽的坑:asyncio.create_task 返回的 Task 如果不被任何变量持有,可能在完成前被 Python GC 回收(官方警告),表现是锁和日志句柄永远不释放。所以要把 task 放进一个模块级 set 持有强引用,完成时由 add_done_callback 自动移除。

坑六:PATH 不依赖环境变量

pi 二进制路径硬编码在 config,不依赖 PATH 查找。理由有两个:一是 FastAPI 进程的 PATH 可能和用户 shell 的不一样(尤其是 systemd 启动、容器部署时),依赖 PATH 会出”明明命令行能跑,服务里找不到”的玄学问题;二是 PATH 是可被污染的环境变量,依赖它等于把执行权交给了环境。硬编码路径不优雅,但确定。我宁可写死一个绝对路径,也不愿意在生产环境排查”为什么 pi 找不到”。

原子化对话存储:写文件这件事并不简单

对话存档是 JSON 文件,写文件听起来是最简单的事,但要做到并发安全且崩溃不丢数据,得用上两个手段。第一是原子写:先写到 {id}.json.tmp 临时文件,flush + fsync 落盘后,用 os.replace 原子重命名到最终路径。os.replace 在 POSIX 上是原子的,要么看到旧文件要么看到新文件,不会看到写一半的半成品。临时文件必须和目标在同一目录(NamedTemporaryFile(dir=conv_dir)),否则 os.replace 跨文件系统会退化成复制加删除,原子性失效。

第二是进程内 asyncio.Lock,每个 conversation_id 一把锁,所有读改写都在锁内。对话追加消息是典型的读改写场景:读出 JSON → append 一条 → 写回。如果不加锁,两个并发请求可能都读到旧版本,各自 append 后写回,后写的覆盖先写的,丢一条消息。锁以 cid 为粒度,不同对话互不阻塞。

这套方案有一个明确局限:asyncio.Lock 是进程内锁,只在单 uvicorn worker 时有效。多 worker 部署需要换文件锁或分布式锁。我坦白地把这条写进 README 的”已知限制”,因为这是一个设计边界——单人玩具不需要多 worker,硬上分布式锁是过度工程。启动时还会扫一遍 conversations/*.json.tmp 残留(cleanup_tmp_files),把上次崩溃留下的半成品临时文件清掉,避免下次启动看到垃圾。

几个工程化取舍

多提供商 LLM 架构是 v2 加的。所有 LLM 配置(base_url、api_key、模型清单、常用模型、默认模型)落盘到一个 gitignore 的 model_providers.json,由浏览器「模型管理」面板维护。我刻意不走环境变量、不走 bws、不放进 config.yaml,理由是 api_key 不进 git 是硬约束,配置变更不应该触发重启。多个 OpenAI 兼容提供商(DeepSeek、智谱 GLM、中转站)可以同时配置,设「常用模型」收藏集后主界面下拉只显示常用,避免在几十个模型里翻找。GET /api/providers 回传时 api_key 脱敏成 sk-1********ef,不泄露真 key。

v2 参与者模型把 AI 角色和人类扮演角色统一成同一种 CharacterFile,“谁来控制”(ai/human)是对话参与者属性,不是档案属性。这个抽象让我能支持多 AI 对话:选两个以上 AI 角色,点「推进一步」按钮,按 round-robin 让轮到的 AI 生成发言。拼装 prompt 时按当前发言者视角重映射历史——发言者自己的历史发言变 assistant,其余变 user,这样模型收到的始终是”我的发言历史 + 别人对我说的话”,能延续自己的声音。

测试驱动验证 prompt 逻辑是这套设计最让我安心的地方。prompt 拼装是纯函数(prompt_builder.py 无 IO),可以独立单元测试。LLM 客户端抽 stream() 接口,测试注入 FakeLLMClient370+ 条测试跑全流程不需要真实 api_key。这意味着每一次拼装逻辑的修改——历史截断规则、宏替换、token 预算——都能用测试锁死行为,不用连真模型手测。Prompt 工程最大的痛点是”改一处影响不可知”,测试把这个不可知变成了可知。

可迁移的原则

把上面这些取舍提炼一下,我觉得有两条原则是跨项目可迁移的。

第一,确定性优于概率性。 原子锁用 O_EXCL 而不是 check-then-set;文件名过滤 - 开头而不是”应该不会有人这么命名吧”;PATH 硬编码而不是”环境变量应该对吧”。每一个选择都是把”大概率没事”换成”确定没事”。AI 应用本身已经充满了概率(模型输出不可控),工程层面再引入概率性就是雪上加霜。凡是能用确定性手段消除的猜测,都该消除。

第二,隔离优于复用。 快路径(对话)和慢路径(重建)彻底分离,不共享代码也不共享状态;发言者和非发言者的信息隔离,防”开天眼”;子进程用 --no-skills --no-extensions --no-context-files 隔离 pi 的项目上下文,只给 read+write 工具;测试用 FakeLLMClient 隔离真实 API。复用是耦合的甜衣,隔离是稳定的基石。当一个系统开始到处复用、到处共享,它的故障传播路径也就铺好了。

这两条原则贯穿整个项目。它们不是设计模式,不是框架,是遇到岔路时的决策方向

诚实坦白局限

这个项目有一堆没做的事和做不到的事,我列在 README 的”已知限制”里,这里挑几条重要的说。

pi 重建写回 MD 无 schema 校验。pi 是外部 CLI,直接写回角色 MD,服务端不解析它的输出,契约只靠 prompts/rebuild.md 约定。如果 pi 哪天输出格式漂了,档案结构可能异常。我把它登记为知情项(YAGNI),v1 不强制服务端校验——因为强制校验意味着我要维护一套 schema,而 MD 的灵活性恰恰是三合一的核心,加 schema 等于自废武功。风险靠 git 兜底,重建前 commit 一次,出问题 reset。

流式哨兵可被模型伪造。流式结束标记 <<<STREAM_END:ok>>> 是明文约定,理论上模型可以在输出中伪造这个标记导致前端提前结束。单人玩具场景风险可接受,加固的话要改成带校验和的哨兵或 HTTP trailer,我没做。

Markdown 渲染为纯文本。前端用 textContent 渲染,不解析 MD,这是为了防存储型 XSS(配合 CSP default-src 'self' 加 per-request nonce)。代价是对话区看不到加粗、列表这些格式。v2 可能加安全的 MD 渲染,但要权衡 XSS 面,不是无脑加。

单进程假设。前面说过,asyncio.Lock 只在单 worker 时防竞态。多 worker 部署要换分布式锁。这条限制直接约束了部署形态——这个引擎不适合直接暴露公网、给多用户用,它就是一个单人或小圈子的本地玩具。我不会假装它能做更多。

收尾

roleplay-engine 不是要替代任何东西,它是一次”砍到不能再砍”的实验:砍掉数据库、砍掉编排框架、砍掉向量库、砍掉在线成长,看一个角色对话引擎最少需要什么。答案是出人意料的少——几个 MD 文件、一个 JSON 日志、一个事件循环、一个会跑子进程的工程纪律。剩下的复杂度,要么是想象出来的,要么是没想清楚就堆上去的。

如果你觉得这套思路有用,欢迎去仓库翻代码。如果你觉得它”太简陋了、缺了 XX”,那你大概不是目标用户——单人玩具的价值就在于它可以选择性地不负责任。我把局限写在 README 里,把坑写在文件头注释里,把决策写在架构文档里,剩下的就交给读者自己判断了。