我最近写了一个状态化角色对话引擎 roleplay-engine。它跑在 FastAPI 上,底子是 Markdown 文件加一个 JSON 对话日志。这篇不讲它有多少功能,只讲两个我觉得值得反复看的设计取舍。第一个是把存储格式、prompt 格式、维护格式压成同一种东西。第二个是在事件循环里安全地拉起一个会跑几分钟的 AI 子进程。后一件事听着朴素,实际的坑很密,我逐条记下来,希望后来的人别再踩一遍。
如果你在做 AI 工程,尤其是想搭一套有记忆、有状态的对话系统,这篇大概能给你一个反面参考。你可能正在纠结:要不要上向量库,要不要用 LangChain 这类编排框架,要不要专门给角色建模设计一套数据库 schema。我的结论是,很多复杂度是自找的,砍掉之后世界并不会塌。仓库在这里:git.ruochongliang.top/lrc/roleplay-engine。
核心洞察:存储、prompt、维护,我为什么合成一份
这个项目最核心的一句话写在 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_locks 用 os.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 配置都落盘到一个被 gitignore 的 model_providers.json,由浏览器的「模型管理」面板维护。里面有 base_url、api_key、模型清单、常用模型、默认模型。我刻意不走环境变量、不走 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() 接口,测试注入 FakeLLMClient,370+ 条测试跑全流程不需要真实 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 里,把坑写在文件头注释里,把决策写在架构文档里,剩下的你自己判断就好。