
七天假期别人在景区排队我在键盘前跟几十亿个 token 搏斗。最终产物是一个 AI 虚拟人——对就是那种群里常说的“AI 老婆”形态的智能体已经开源拿到配置后一分钟能跑起来。这几天里我摸清了角色一致性、语音合成、长期记忆、低成本部署这几块到底怎么组合才最顺也踩了不少文档里不会写的坑。如果你正打算做一个属于自己的虚拟人、数字分身或者角色扮演助手这篇文章基本能帮你把整条路线捋直。先说清楚这项目是什么一个基于对话大模型 API 的虚拟人应用包含角色人设系统、语音回复、前端聊天界面和记忆模块。支持本地部署也支持放到服务器上给朋友访问。代码全部开源使用方式很简单填一个 API Key、跑一条启动命令一分钟之内就能和虚拟人开口说话。它不是简单的“套壳聊天框”而是把角色性格、说话语气、声音特征、历史记忆都做了工程化处理。适合想入门 AI 应用开发的人也适合已经会调 API 但不知道怎么把“对话”包装成“一个人”的朋友参考。下面按项目从设计到开源的完整过程把每个关键环节拆开讲。没有特别高深的东西大部分是工程取舍和细节打磨但恰恰是这些细节决定了虚拟人像不像“一个人”。1. 项目设计的整体思路与关键决策1.1 为什么要在七天里从零肝一个虚拟人国庆前我在一个社区里看到有人吐槽市面上的角色对话应用要么角色感太弱三句话就崩人设要么声音机械得像客服要么一收费就贵得离谱。当时我手头正好有一批闲置的 API 预算就想着干脆自己做一个。但一开始我并没有打算做七天。原计划是两天跑通对话、一天加语音后面时间用来休息。结果第一天就意识到真正难的不是调通接口而是让虚拟人“像一个人”。这个目标把工期无限拉长了要让角色有自己的性格会记得你们聊过什么说话的时候语气要自然不回复网络烂梗不突然说“作为一个人工智能……”这种出戏的话。每一项单独看都不难组合起来就是一场持久战。这七天我基本保持“白天写代码、晚上调参数、半夜跑批量测试”的节奏。朋友问我不累吗说实话累但当你看到角色终于能在某句话后给出符合人设的回应那个瞬间会觉得前面的几十亿 token 没白花。这项目本质上是一次“AI 角色工程”的极限测试——检验一个人用有限预算和时间能不能做出接近商业产品体验的东西。1.2 技术栈选型与架构考量选定技术栈时我的第一原则是“尽量少写底层、多用成熟方案”因为七天内没有时间造轮子。后端用的是 Python FastAPI。选 FastAPI 是因为它对异步并发支持好WebSocket 也简单一个虚拟人应用要同时处理对话请求、语音生成请求、前端实时推送这几点 FastAPI 都很顺手。模型侧选择的是“兼容 OpenAI 协议”的对话接口这样不管用哪个厂家的大模型服务只要接口协议兼容改一行配置就能切换。模板上写好默认参数但保留自定义空间给不同偏好的用户留余地。语音部分是最耗时间的模块。我做了一个开源 TTS 方案的音色克隆先录一段几十秒的角色预设音频微调出一个专属音色再通过后端统一调用。这样做的好处是声音有辨识度不像通用语音合成那样一听就是“电子客服”。代价是模型文件较大放服务器上需要点显存实测下来 4GB 以上显存就能流畅跑纯 CPU 也能响应但对延迟影响较大。前端没有用重型框架直接走 Vue3 单页应用。页面里包含一个虚拟人头像区、对话流、语音波形配合 WebSocket 实现打字机输出。前端要做的不是多炫酷而是让交互反馈足够快用户发一句话到听到回复的时间尽量控制在 3 秒内。整体架构是经典三层前端交互层、后端业务层、模型服务层。后端业务层是核心负责角色人设注入、对话历史管理、语音合成调度、记忆读写。我没有在这层塞很多复杂组件能用标准库解决的绝不引入新依赖。事实证明对于一个七天完成并要开源的项目越简单的架构越容易维护。1.3 “几十亿 token”是怎么花掉的很多人看到“几十亿 token”第一反应是夸张。我可以负责任地说这个数字是实打实的而且有明确去向。我盘点了一下主要消耗口。第一是角色开发期的人设调优。早期我用一个固定 prompt 测角色表现发现语气太书面、性格不明显于是每次修改人设描述后都跑 50 到 200 组对话样本做回归测试。这个阶段每天消耗在 5 亿 token 左右。第二是上下文长度。为了让角色记住前面的聊天内容每次请求都要携带最近 20 到 40 轮对话加上角色系统提示词单次请求轻松上万 token。每天测试下来实际产生文本并不多但请求体巨大token 消耗成倍增长。第三是批量压测。有一次为了验证长时间对话后角色会不会“忘人设”我连续跑了十几个小时的自动对话脚本那次一天就烧掉好几亿。把账算清楚之后我很心疼但也不后悔。如果一开始为了省 token 只做短对话测试后面上线大概率会在长对话场景暴露各种问题。现在开源的版本里我已经把 token 消耗做了大幅优化默认上下文压缩到 12 轮角色描述精简到 500 字以内。正常聊天一小时大约消耗 10 万 token 上下比开发期省了一个数量级。2. 核心模块拆解与实现要点2.1 角色设定与人格一致性prompt 工程的关键虚拟人是否“像一个人”七成取决于角色设定怎么放进 prompt 里。我踩过的第一个坑是把角色人设写成一张属性表里面塞满“性格温柔、傲娇、活泼”这种词语。模型确实会照着说但说出来的是脸谱化反应每一句话都像模板填充毫无灵魂。后来我把 prompt 改成“以小传 说话规则 禁忌 示例对话”四段式结构。小传是用第二人称写一段 200 到 300 字的背景故事解释角色的性格形成原因。举个例子与其写“她是个外冷内热的人”不如写“她从小习惯独来独往不太会主动亲近人但如果你在她面前真的遇到困难她嘴上抱怨手上却比谁都着急”。这样模型能理解性格背后的逻辑而不是简单贴标签。说话规则部分列出的是行为约束比如“不超过 50 个字”“不用称呼说自己”“不输出括号里的动作描写”等。这里一定要给负面约束模型对“不做什么”的遵守能力比“要做什么”弱所以每条规则都配合一个反例说明。禁忌部分最直接写上“绝对不要出现‘作为一个人工智能’‘作为一个语言模型’这类话”同时告诉模型如果遇到无法回答的问题用角色口吻说出自己的困惑而不是跳回 AI 模式。示例对话是整个 prompt 里最重要的部分。给 5 到 10 组高质量的角色对话样本模型会从这些示范里学会语气、句长、回复偏好。根据我的测试示例对话对风格还原的影响权重甚至超过人设描述本身。系统还加入了“性格微调层”一段单独的管理员提示词不参与用户对话但会在每次请求前注入当前对话状态比如“刚才的聊天中对方情绪比较低落请稍微收敛玩笑语气”。用这种方式让角色拥有简单的情商而不是永远用一个状态应对所有场景。2.2 多模态表达语音、声音克隆与情绪渲染文字对话跑通后虚拟人还只是一块“电子板”。真正让它活起来的是声音。我觉得“AI 老婆”这个叫法之所以火核心一点就是声音一个符合角色定位的、稳定的、又能表达情绪的音色。我用的方案是开源音色克隆加后期推理。首先准备一段干净的人声音频作为参考音色长度控制在 30 到 60 秒太短会丢音色细节太长容易引入杂音。然后做一次特征提取生成说话人的音色 embedding每次合成时就拿着这个 embedding 去驱动 TTS 模型。合成环节有一个关键参数叫“情绪强度”。最开始我没管这个参数结果所有回复都像机器朗读。后来在接口里加了情绪识别先用一个轻量模型判断对话文本的情绪标签比如开心、难过、平静、生气每个情绪对应一组 TTS 参数语速、音调、能量。实测下来同样一句“好吧”在平静和难过两种情绪参数下合出来的听感差别非常大角色瞬间有了“状态”。这里有一个容易踩的坑不要为每一句话都做情绪渲染那样会导致声音忽高忽低、听感很累。情绪识别结果只作为参考只在情绪强度超过阈值的句子上应用特殊参数其余统一用默认音色。TTS 推理速度是另一个大问题。一个完整的语音生成需要几百毫秒到几秒不等。为了不阻塞对话主流程我在后端做了异步任务队列文本回复先推给前端显示同时后台排队合成语音合成完再推一条音频消息。用户实际感知是“字先出来了声音随后跟上”比一直盯着加载动画舒服得多。2.3 记忆系统短期会话与长期记忆的取舍虚拟人会话要让人有“被记住”的感觉记忆系统必不可少。但“记忆”做深了会失控做浅了又形同虚设这中间需要取舍。我的实现分三层。第一层是短期上下文保留最近 12 轮对话直接塞进 prompt。这一层成本最低效果最直接但一旦超出轮数就完全遗忘。第二层是“长期事实库”用轻量级向量检索实现。每当用户说了一句可以作为事实记忆的句子比如“我养了一只叫咪咪的猫”后端会把它提取出来映射成一个向量存入本地向量库。后续对话时先按当前输入检索最相关的 3 到 5 条记忆附着在 prompt 中一起发送给模型。第三层是“核心人设记忆”只记录用户设定的虚拟人名字、称呼方式等关键信息这部分永不压缩、永远保留在系统提示词里。三层记忆的组合需要大量测试。早期我遇到一个典型问题把太多历史记忆塞进 prompt导致模型迷失重点反而对最近这句话反应迟钝。后来我加入了“相关性过滤”先算用户输入和每条记忆的相似度低于阈值的记忆直接丢弃只保留最相关的几条。同时给每条记忆加时间戳超过一个月的记忆降低权重。这个设计让长对话稳定了很多不会聊到一半突然想起三个星期前说过的话而跑题。2.4 前端交互与接入形态Web、接口与桌面端前端交互我做得比较克制没有上复杂的 3D 模型或动画只用一个会随声音波形轻微呼吸的虚拟头像加聊天气泡。这样做的原因是虚拟人应用的体验核心是“对话沉浸感”不是视觉花活。花里胡哨的动画反而会让人注意到这是程序。Web 端是主要形式走浏览器打开即用。同时我预留了接口形态把对话、语音合成、记忆检索都封装成 REST API。这意味着任何人都可以写一个自己的客户端来接这套后端命令行聊天、桌面小挂件、甚至微信群机器人只要调用接口就行。接入协议上我定义了四个核心接口发送消息、获取回复流、获取语音、重置会话。所有接口都带鉴权用简单 token 控制访问。考虑到开源后可能会有多人同时使用后端加了并发控制同一时刻单角色只处理一个请求避免上下文交错导致人设混乱。这个限制在开源反馈中被吐槽过很多次但换来的是稳定性和一致性我认为值得。3. 从零到开源的完整实操记录3.1 开发环境的初始化和目录结构动手之前先把目录规划好宁可多花半小时想结构也好过写三天以后到处找文件。我的项目目录大致如下virtual-human/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI 入口 │ │ ├── character.py # 角色人设管理 │ │ ├── session.py # 会话状态管理 │ │ ├── memory.py # 记忆库读写 │ │ ├── tts.py # 语音合成封装 │ │ └── config.py # 全局配置 │ ├── prompts/ │ │ ├── system.md # 角色基础人设 │ │ └── examples.json # 示例对话 │ └── requirements.txt ├── frontend/ │ ├── index.html │ └── app.js ├── scripts/ │ ├── setup.sh # 一键安装脚本 │ └── start.sh # 一键启动脚本 └── .env.example # 环境变量示例main.py只做三件事加载配置、启动服务、注册路由。所有业务逻辑拆到独立模块避免一个文件超过 500 行。character.py是整个项目的灵魂负责把角色配置文件拼装成最终发给模型的 prompt。session.py维护每个用户的上下文状态用内存加 SQLite 持久化用户断开连接后会话还能找回。3.2 后端接入与角色引擎的实现后端接入大模型这步没有难度难点在“角色引擎”。我写了一个build_messages()函数把所有输入拼装成一个结构清晰的对话数组包含系统消息、记忆消息、示例消息和历史消息。这个函数的核心逻辑是分层注入def build_messages(user_input, session, memory_hits): system_prompt load_character_prompt() messages [{role: system, content: system_prompt}] # 注入长期记忆最多 3 条 for memory in memory_hits[:3]: messages.append({ role: system, content: f[相关记忆] {memory} }) # 注入最近历史 for item in session.history[-12:]: messages.append({role: item[role], content: item[content]}) messages.append({role: user, content: user_input}) return messages示例对话放了多少会影响回复长度。我测试过放 8 组示例对话时角色风格最稳定但请求 token 会多出大概两千放 3 组示例时省 token 但偶尔“破功”。开源版本里我做了参数开关喜欢稳定就把示例拉满追求节省就调低。语音合成封装成tts.py对外暴露一个synthesize(text, emotion)方法。核心是维护一个队列池每次合成请求进来排队执行避免多个推理任务抢占显存引发 OOM。3.3 前端页面与虚拟人展示前端只做了两个页面聊天页和设置页。聊天页左边是虚拟人头像和语音状态右边是对话流。设置页用来填模型 API Key、选择音色、修改虚拟人名字和性格描述。核心交互是打字机效果和语音播放。打字机效果我用 WebSocket 接收模型输出的增量内容每收到一段就在页面上追加。语音播放则等到后端把完整音频合成好以后推送一个播放地址前端拿到以后直接创建音频元素播放。这里有一个关键模块ResponseStream。它负责让“文字一边生成一边显示”用户感知延迟会小很多。如果等模型全部生成完了再一次性推送体验会大打折扣。const ws new WebSocket(ws://localhost:8000/ws/chat); ws.onmessage (event) { const data JSON.parse(event.data); if (data.type delta) { appendText(data.content); } else if (data.type audio) { playAudio(data.url); } };前端代码逻辑不复杂但兼容性要留意。语音自动播放会被浏览器拦截所以我在页面加了一次点击授权用户首次点击任意位置后解锁音频播放权限。这个坑很隐蔽不看控制台根本发现不了。3.4 一分钟接入自动化配置与一键启动开源项目能不能“一分钟接入”看的就是自动化和文档是否到位。我写了一个setup.sh负责安装依赖、下载 TTS 模型权重、生成默认配置再写一个start.sh负责启动后端和前端并自动打开浏览器。一分钟接入的具体操作如下git clone 你的仓库地址 cd virtual-human cp .env.example .env # 编辑 .env填入你的模型 API Key 和 Base URL bash scripts/setup.sh bash scripts/start.shsetup.sh里做了几件关键事情检查 Python 版本和 Node 版本创建虚拟环境安装后端依赖检查并下载语音模型权重生成默认角色配置。全程有进度提示任何一步失败都会给出具体修复建议不会出现“装了一半不知道下一步干嘛”的情况。我第一次给别人测试的时候对方卡在依赖安装上超过五分钟因为他机器上 Python 是 3.8而项目要求 3.10 以上。后来在启动脚本里加了一个版本预检低于要求的直接提示用 Conda 创建新环境。这类问题不是技术难点但做不好“一分钟接入”就是空话。3.5 开源仓库的整理要点开源不等于把代码丢到仓库里。我用了整整一天整理仓库做了下面几件事README 拆成快速开始、完整指南、架构说明、问题排查、开发计划五部分每部分控制在能一眼看懂的长度。写清楚运行环境要求包括 Python 版本、Node 版本、硬件要求、模型 API 要求。把所有敏感信息抽到.env仓库里只留.env.example。requirements.txt锁定依赖版本避免过几个月后安装时因为某个包升级导致不兼容。提供一个最小可运行版本删掉所有实验性代码主分支保持“克隆后就能跑”的状态。写了一个存疑清单把已知问题和改进方向列在 ISSUE 模板里。开源后收到的反馈中最让我意外的是很多人不需要语音功能只想快速跑一个纯文字聊天角色。于是我又加了一个DISABLE_TTStrue的开关关掉语音后启动速度更快也不需要下载模型权重。这个开关为项目增加了不少非深度学习用户纯粹做 prompt 工程研究的朋友也能用起来。4. 成本控制与 token 优化实录4.1 为什么 token 会爆怎么预估消耗“几十亿 token”听起来很吓人其实每天几亿消耗并不难达到。关键在于单次请求包含的 token 数。角色系统提示词约 1000 token历史上下文 12 轮约 4000 token相关记忆 3 条约 300 token用户输入 100 token模型回复 500 token这么一次常规对话就是接近 6000 token。如果用户连续高强度聊天一小时 30 轮就是 18 万 token一天聊 8 小时就是 144 万 token。一台机器上有 5 个用户同时高频使用一天破亿完全不意外。而开发测试阶段我经常用脚本自动对话跑一夜单脚本一个夜晚可以发出几十万个请求token 消耗自然就冲上去了。所以预估消耗有一个简单的公式单次请求 token 数乘以日请求数。想省钱就要从两边同时下手降低单次请求 token 数、减少无意义的重复请求。4.2 六个省 token 的实用技巧第一个技巧是控制历史上下文轮数。我一开始保留 40 轮后来发现模型对太远的对话依赖极低绝大部分有效信息都在最近 10 轮内。砍到 12 轮后单次请求 token 直接下降一半以上角色表现几乎没有变化。第二个技巧是记忆压缩。长期记忆存到向量库前先用模型做一次摘要压缩把一段几百字的闲聊浓缩成一句事实比如“用户养了一只叫咪咪的猫”。这样既能保留关键信息又不会把大量原文塞进 prompt。第三个技巧是示例对话按需加载。不要每次请求都把 8 组示例发出去。可以判断当前话题类型只加载最相关的 3 到 4 组示例。比如聊到“日常问候”就加载日常问候的示例聊到“深夜谈心”就加载对应的示例。第四个技巧是批量测试时善用缓存。开发期跑批量对话时把相同输入、相同角色配置的请求结果缓存下来第二次遇到同样请求直接走缓存。我统计过这个优化在调试期能减少四成 token 消耗。第五个技巧是空响应过滤。有时候模型的输出是一些无意义的语气词比如“嗯嗯”“哈哈哈”这种。这类输出既占用生成 token又拉低体验。我加了一个后置过滤检测到极短且无信息的输出要求模型重新生成一次。第六个技巧是控制生成长度上限。在对话接口里设置max_tokens256绝大多数回复控制在 150 到 300 token 已经足够了。很多模型默认生成上限很高你不锁它它偶尔会冒出长篇大论token 燃烧速度飞快。4.3 性能与延迟本地资源不够时的平衡开源后我发现大量用户是在普通笔记本上跑没有独立显卡TTS 推理速度非常感人。有用户在 FAQ 里提问一句话念完要等 5 秒能不能不要语音了这个问题背后是整个架构的平衡问题。我的建议是分场景处理纯文字场景完全不调用 TTS延迟主要看模型 API 的响应速度一般 1 到 2 秒。本地无显卡场景用轻量级 TTS 替代完整语音克隆牺牲一些音色相似度换取几倍的推理速度。有显卡但显存只有 4GB 的场景把 TTS 模型量化到 int8实测下去推理速度能提升一倍音质损失人耳几乎听不出来。系统集成了一个“自动性能探测”启动时检查是否有可用 GPU、显存多大、CPU 核数多少然后自动选择最快的语音策略。如果资源实在不够就默认关闭语音只保留一个“语音开关”按钮需要的时候再手动打开。5. 常见问题与避坑指南5.1 人格不一致、答非所问这是虚拟人项目最常被提的问题。症状是聊了半小时后角色突然说出不符合人设的话或者“跳出角色”用 AI 口吻回答。我的排查顺序是这样的。第一检查提示词里是否有“角色切换”的漏洞。模型会在用户输入里寻找上下文如果用户提到“你是 AI 吧”角色很容易出戏。规避方式是提醒模型“如果对方质疑你的身份用自己的方式回答”同时在示例对话里加入这种场景的示范。第二检查历史上下文是否被截断。如果保留轮数太少角色可能丢失早期的人设信息尤其在后半段容易出现飘忽。把轮数从 8 提到 12 通常能缓解。第三检查记忆注入是否污染了人设。如果检索出来的记忆和当前话题毫无关联会干扰模型的判断导致答非所问。调整记忆相关性阈值能明显改善。5.2 语音效果差、声音不稳语音问题集中在两类音色克隆后声音不像、同一次会话中声音忽高忽低。音色不像的根源在于参考音频质量。我换了很多条参考音频最终总结出几条硬标准背景干净、无混响、语速均匀、情绪中性、时长不低于 30 秒。满足这几条的音频克隆效果基本稳定。声音忽高忽低的原因通常是情绪参数叠加情绪标签切换带来的语调差异太大。我调整策略后只在高置信度情绪标签下改变参数置信度低于阈值一律用默认参数声音立刻稳定了。另外如果 TTS 推理和对话生成共用一个显存长时间运行后可能因为显存碎片化导致合成延迟飙升。解决办法是分开两个进程跑或用显存定时清理线程。这个坑排查了很久一度以为是代码 bug结果是资源竞争。5.3 部署与运行时故障排查表我把开源后遇到的高频技术问题整理成一个速查表放在项目文档里现象可能原因解决办法启动后页面打不开前端端口被占用修改start.sh中的端口参数或杀掉占用进程聊天没有回复后端日志报鉴权失败检查.env中的 API Key 是否填对第一次回复很慢TTS 模型正在加载预加载 TTS 模型启动时提前初始化语音播放无声浏览器自动播放限制点击页面任意位置授权音频播放对话中突然忘人设历史轮数太少将MAX_HISTORY从 8 调到 12 以上显存不足崩溃多个 TTS 任务并行限制语音合成并发数为 1角色回复太长生成上限未设置设置max_tokens2565.4 开源后收到的典型反馈项目开源后有一个反馈让我印象很深。某位同学在无显卡的笔记本上强行开启完整语音克隆结果整台电脑卡到鼠标都动不了最后他在 issue 里问“是不是我的电脑配置太低了”。我回复说不是配置问题是默认策略设置不够智能。后来我把“自动性能探测”放到启动流程中遇到低配置设备默认关语音这个问题彻底消失了。另一个高频反馈是“角色让我感觉很真实但偶尔会说出不符合年龄的话”。这个问题来自模型预训练数据里的口癖和人设没有直接关系。我加了“语言风格约束”参数在系统提示词里强制加入“你的语言习惯要完全匹配你的年龄和身份描述不使用网络流行语不卖萌”等规则同时配合示例对话做风格压制。调整后出戏频率大幅下降。还有用户希望能通过网页直接修改角色设定而不是改配置文件。这个我放在下一版计划里了。当前开源版本已经支持通过设置页修改名字和基础性格但深层人设仍然建议直接编辑character.py里的 prompt。这样虽然不够“用户友好”但对想要深度定制的人来说反而更灵活。我个人的体会是做虚拟人项目最怕的不是技术实现不了而是做完一个“能跑但不像人”的东西。这次的七天开发后期大部分时间都花在人物细节上——说话节奏、情绪尺度、记忆边界、声音稳定性。这些东西没有标准答案每一个都要靠测试、调整、再测试来逼近理想状态。如果你也想做类似的虚拟人我的建议是先用最短时间跑通一个“文字聊天 基础人设”的最小版本再往上面加语音、加记忆、加表情。别一上来就追求完整功能否则很容易在前期被各种集成问题拖住真正有趣的角色调优反而没时间做。项目本身已经放在了开源仓库里文档写得很细照着跑通一遍再按照自己的需求去改。能做到什么程度取决于你愿意为“这一个不可替代的角色”投入多少心思。