iFakeSiri实践:可控对话脚本与语音合成搭建虚拟助手 说真的第一次看到 iFakeSiri 这个玩法的时候我脑子里冒出来的第一个念头是这不就是个会说话的对话框吗有什么好折腾的等我自己动手搭了一遍才发现自制 Siri 对话这件事的难点从来不在界面而在于你怎么让一段预先写好的剧本在真实的问答里显得不像剧本。虚拟语音助手这类东西用户对它的容错率低得离谱——只要有一句答非所问、有一次语音和动画错位、有一次音色突然跳变那种这是个人工智能的错觉立刻碎掉。所以 iFakeSiri 这个项目的价值其实是把一个看起来很唬人的交互形态拆成可以自己掌控的几块对话脚本、匹配逻辑、语音合成、视觉反馈。它适合三类人想做互动内容但不会写复杂后端的创作者、想给语音交互产品做低成本原型的开发者、以及单纯想整一个能对话的玩意放在展台或视频里的玩家。下面我按我自己踩过坑的顺序把整个搭建过程从设计到调参全部摊开讲。1. 先想清楚假在哪里需求拆解与路线选型1.1 用户要的不是智能是可控很多人一上手就想着接个生成式模型觉得那样才叫智能。我实测下来恰恰相反这个项目最核心的卖点是可控。自制对话脚本意味着你可以让助手说出任何一句话可以埋彩蛋可以设计剧情分支可以让它在特定问题上有固定反应——这些恰恰是通用助手做不到的。我见过拿它做展台讲解的把产品介绍写成对话节点观众随便问都能被引导到核心卖点上也见过拿它做视频素材的提前设计好十几轮来回拍摄时一次过。从需求角度拆使用者真正想要的是四样东西一是回复内容完全由自己决定二是能否离线运行不被网络和账号限制三是开发门槛低最好一个 HTML 文件双击就能跑四是外观上像那么回事能唬住第一次见的人。这四条决定了技术选型的方向也决定了你后面写代码时该优先优化什么。1.2 三种实现路线的横向对比我把常见的三条路线都跑过一遍各自的脾气差别很大直接列个表更清楚。路线可控性开发成本离线能力穿帮概率适合场景硬编码 if-else极高极低完全离线低但覆盖窄只有三五个固定问答的演示对话库 模糊匹配高中等完全离线中需调阈值大多数自制场景推荐接生成式模型自由作答低中等偏高依赖网络高容易跑偏需要开放式闲聊的场合硬编码的问题在于扩展性写到第三十个 if 的时候你自己都不想维护了。生成式模型的问题在于不可控你永远不知道它会补出什么话做展示的时候一句意外回答就能把气氛毁掉。所以我最终选的是中间那条用结构化的对话库做主体用模糊匹配做召回把兜底话术当作安全网。这样做的好处是每一句输出都在你的剧本范围内同时又允许用户用不同的说法提问。这个取舍背后有个很朴素的判断观众在意的是它听懂我了而不是它答得多精彩。只要匹配得上哪怕是句很平淡的回复体验也是成立的匹配不上再华丽的措辞也救不回来。1.3 最小可用闭环该长什么样一个能跑起来的闭环其实只有六个环节采集声音 → 转成文字 → 文本归一化 → 匹配对话节点 → 填充变量并选句 → 合成语音播放同时驱动动画。这六步里真正花时间的只有第一步和最后一步中间四步在浏览器里几百毫秒就能跑完。我给这个闭环定的验收标准很土但很实用从用户说完最后一个字到扬声器里出现第一个音节总延迟控制在 800 毫秒以内。超过这个数人就会开始怀疑它是不是没听见然后重复说话整个节奏就乱了。后面所有关于参数和代码的选择都是围绕这个 800 毫秒的预算在做减法。2. 对话脚本的骨架让剧本能被程序读懂2.1 一个对话节点到底该有哪些字段写对话库最容易犯的错是把所有东西塞进一个大对象里改到后面连自己都看不懂。我建议一开始就把字段定死宁可留空也别乱加。我自己用的字段集是这样的id唯一标识方便跳转和调试patterns触发这个节点的若干说法reply回复候选数组多条时随机取避免重复感slots需要动态填充的变量名next回答完之后把上下文切到哪个节点emotion表情或语气标签用来驱动动画weight权重同分时优先选谁require前置条件标记比如必须先问过名字才能称呼用户。这里特别说两个容易被忽略的reply一定要是数组哪怕你只写了一条。真人说话不会每次一模一样同一个问题连续问三次得到三种稍微不同的回答那种活着的感觉是单条回复给不了的。另一个是next它是实现多轮对话的关键没有它你的助手就只能一问一答永远进不了聊天的状态。{ meta: { name: demo-assistant, lang: zh-CN, fallback: fallback.generic }, nodes: [ { id: greet, patterns: [你好, 在吗, 嗨, hello], reply: [在的{nickname}今天想聊点什么, 我在随时可以开始。], emotion: calm, next: idle }, { id: ask_time, patterns: [现在几点, 几点了, 报个时间], reply: [现在是 {time}。], slots: [time] }, { id: who_are_you, patterns: [你是谁, 你叫什么], reply: [我是一段你自己写的对话脚本名字叫 {nickname}。], next: self_intro }, { id: fallback.generic, patterns: [__fallback__], reply: [这句我没写进剧本里换个说法再问我一次] } ] }看到{nickname}和{time}这种花括号了吗这是整个设计里性价比最高的一个小技巧。用户一旦在第一轮报了名字后面所有回复都能自然地带上称呼观感上立刻从查表机器人变成记得我的助手。而实现它只需要在匹配前做一次槽位填充几行代码的事。至于fallback.generic节点它不需要被正常匹配到只作为兜底返回所以我在 patterns 里放了一个永远不会被用户说出来的占位符避免它干扰正常匹配。2.2 匹配算法从包含到二元组相似度匹配这块我走过两个弯路。第一版是纯includes判断用户说你好啊pattern 是你好能命中用户说您好直接失效。第二版我加了个同义词表把所有礼貌用语都列进去结果维护成本爆炸。最后定下来的方案是三层递进先做文本归一化再依次尝试精确匹配、包含匹配、字符二元组相似度取分数最高的那个低于阈值就走兜底。归一化这步别看简单它能解决掉大半的明明意思一样却没命中的问题。要处理的东西包括去掉所有标点和空白、全角转半角、英文字母统一小写、把您替换成你这类称呼归并、把连续重复字符压成一个在吗吗吗→在吗。这几行正则写下来召回率能肉眼可见地提升。const PUNCT /[\s。、,.!?;:“”‘’()【】\[\]]/g; function normalize(s) { return s .replace(PUNCT, ) .replace(/[---]/g, c String.fromCharCode(c.charCodeAt(0) - 0xfee0)) .toLowerCase() .replace(/您/g, 你) .replace(/(.)\1{2,}/g, $1$1); } function bigrams(s) { const set new Set(); for (let i 0; i s.length - 1; i) set.add(s.slice(i, i 2)); return set; } function similarity(a, b) { if (a b) return 1; const A bigrams(a), B bigrams(b); if (!A.size || !B.size) return 0; let inter 0; for (const g of A) if (B.has(g)) inter; return inter / (A.size B.size - inter); }这里用的是二元组 Jaccard 相似度公式是交集大小除以并集大小。举个具体的例子用户说现在几点了归一化后是现在几点了六字二元组是 {现在, 在几, 几点, 点了} 四个pattern 是几点了三字二元组是 {几点, 点了} 两个交集两个并集四个相似度 0.5。如果阈值设在 0.45刚好能命中。为什么选二元组而不是编辑距离因为中文里词序变化很常见几点了现在和现在几点了意思一样但编辑距离不小二元组重合度反而更高。阈值我建议从 0.45 起步脚本量大了之后往 0.5 收一收收太高会漏召回放太低会把不相干的话也吸进来。function match(input, nodes, ctx) { const q normalize(input); let best null, bestScore 0; for (const node of nodes) { if (node.require !ctx.flags.includes(node.require)) continue; for (const p of node.patterns) { const np normalize(p); let s 0; if (q np) s 1; else if (np.length 2 q.includes(np)) s 0.85; else if (np.length 2 np.includes(q)) s 0.8; else s similarity(q, np) * 0.9; if (s bestScore) { bestScore s; best node; } } } ctx.lastScore bestScore; return bestScore 0.45 ? best : nodes.find(n n.id fallback.generic); }注意np.includes(q)这条分支——用户只说几点pattern 是现在几点这时候整句包含关系是反过来的如果不加这条分支就会漏掉。另外similarity我乘了 0.9 的系数目的是让模糊匹配永远排在精确匹配和包含匹配之后形成一个明确的优先级阶梯。调试的时候把ctx.lastScore打印出来你就能清楚看到每一次识别到底是命中了还是兜底了这个字段帮我省了大量排查时间。2.3 上下文与槽位别让第二句话露馅单轮问答做出来只是个会说话的搜索框真正让人觉得在聊天的是上下文。我的做法是维护一个极简的上下文对象里面只放三样东西当前节点 id、已填充的槽位、最近三轮的输入原文。节点切换靠next字段驱动槽位靠关键词抽取或者正则匹配填充。比如用户说我叫小林匹配到一个set_name节点用正则/我叫(.{1,6})/把小林抽出来塞进slots.nickname并给上下文打上hasName标记之后所有带require: hasName的节点才可能被激活。这样设计出来的效果是没报名字之前助手不会乱称呼报了之后称呼自然出现符合真人对话的节奏。上下文还得有过期机制这点很少有人一开始就想到。我最初没做超时结果测试的时候问了时间、又聊了别的隔了五分钟再问刚才那个助手还停留在很早期的节点上回了一句完全接不上的话。后来加了规则上下文里超过 90 秒没有新输入就自动清空业务槽位、只保留昵称并切回 idle 节点。90 秒这个数是我按演示场景的平均停顿估的如果你做的是连续对话体验可以放到 180 秒做展台讲解就压到 60 秒。还有一条经验兜底话术要写三条以上而且不能带抱歉。连续两次抱歉我没听懂会瞬间暴露这是个查表程序换成这句我没写进剧本换个说法再问一次这个我还真不知道你教教我这类反而显得自然甚至有点人味。3. 声音那条链路识别、合成与同步3.1 语音输入的现实表现与兜底方案浏览器里最省事的语音识别就是标准的语音识别接口不用装任何东西几行代码能接通。但它的表现有很强的场景依赖安静室内、标准普通话、离麦克风三十厘米识别准确率还不错换到有背景音的展会现场准确率掉得厉害。所以我从一开始就做了双通道输入语音识别只是其中一条路界面下方永远保留一个文本输入框。这不是妥协是必要的降级设计——当观众在嘈杂环境里说了两遍都没被识别他会自己去点输入框体验虽然打了折但不会卡死在那里。function startListen(onText) { const Rec window.SpeechRecognition || window.webkitSpeechRecognition; if (!Rec) { console.warn(当前环境不支持语音识别请使用文本输入); return null; } const rec new Rec(); rec.lang zh-CN; rec.interimResults false; rec.continuous false; rec.maxAlternatives 1; rec.onresult e onText(e.results[0][0].transcript); rec.onerror e console.warn(识别异常:, e.error); return rec; }有个细节值得单独提continuous一定要设成false。设成true的时候它会一直挂着监听环境噪音一多就不断触发导致助手自己说话的声音被它自己识别进去出现自己跟自己聊的诡异场面。还有个坑是权限这个接口要求页面运行在安全上下文里本地直接双击打开 HTML 文件往往拿不到麦克风权限必须用一个本地静态服务器把页面跑起来。我最初就卡在这里以为是代码写错了折腾了半小时。3.2 语音合成的调参手感合成这块浏览器自带的语音合成接口足够用了关键在于参数。我反复试下来的一组数值是语速 0.95 到 1.05、音高 1.0 左右、音量 1.0。语速低于 0.9 会显得呆滞像播报天气预报高于 1.15 又会觉得在赶时间。中文语音的音高如果调到 1.2 以上容易变成那种卡通吉祥物的调子只在故意做搞笑人格时才用。function speak(text, opt {}) { const u new SpeechSynthesisUtterance(text); u.lang zh-CN; u.rate opt.rate ?? 1.0; u.pitch opt.pitch ?? 1.0; u.volume opt.volume ?? 1.0; if (opt.voice) u.voice opt.voice; speechSynthesis.speak(u); return u; }比参数更重要的是回复长度控制。rate 为 1 的时候中文大约每秒四到五个字一句二十字的回复要念四五秒。四五秒是什么概念足够让对面的人开始走神了。所以我给自己定了个硬规矩单条回复控制在 12 到 22 个字之间需要长内容的拆成两三句用句号断开让人有换气的间隙。停顿也是可以设计的在句子里插一个省略号或者逗号合成出来的节奏会明显更自然现在是……下午三点二十听起来就比现在是下午三点二十更像人在说话。延迟这块我做过一次粗略的测量记录在下面这张表里供你对照自己的实现环节实测耗时优化手段语音识别返回200 到 600 毫秒无法优化取决于环境文本归一化 匹配小于 5 毫秒节点数上千时加索引首句合成出声150 到 400 毫秒缩短首句、预热语音引擎动画启动小于 16 毫秒用 CSS 变换别改布局属性合计大概在 400 到 1000 毫秒之间浮动。想压到更低最有效的办法是首句只说三到五个字比如先说好的。再说正文第一句短出声就快人的感知延迟会显著下降。这个技巧在很多真实语音产品里都在用用的是同一套心理机制。3.3 视觉同步让波形跟着声音动视觉上最容易翻车的是声音和动画各走各的。我见过不少自制版本嘴型是按固定频率循环播放的跟实际发音完全没关系多说两句就露馅。正确的做法是让动画由音频事件驱动。合成接口提供了边界事件每读到一个词就会回调一次用它来驱动嘴型和波形同步误差能压到一帧以内。const mouth document.getElementById(mouth); const utter speak(好的我正在处理。); utter.onstart () mouth.classList.add(talking); utter.onboundary e { mouth.style.setProperty(--amp, (0.4 Math.random() * 0.6).toFixed(2)); }; utter.onend () { mouth.classList.remove(talking); mouth.style.setProperty(--amp, 0); };CSS 那边只需要一个把--amp映射成缩放或高度的动画配合transition做插值。如果你想要更细腻的效果也可以接一个音频分析节点去取实时振幅但说实话对于这种自制场景边界事件已经足够多做的那部分用户根本分辨不出来反而增加调试负担。这里还有一个必须提醒的点外观设计不要去复刻任何真实产品的界面、图标和唤醒词。做一个通用的圆形波纹加几条柱子就够了既好看也不会引起误认。这个边界在下面第 5 章还会再提一次。4. 动手实操从零跑通一个能对话的 Demo4.1 环境准备与目录结构整个项目我用纯前端实现不装框架一个静态目录就够。这样做的好处是拷贝给任何人双击准确说是用本地服务器起一下就能跑不用配环境。目录结构如下ifakesiri-demo/ ├── index.html # 页面结构与样式挂载点 ├── css/ │ └── main.css # 圆形波纹、嘴型、按钮样式 ├── js/ │ ├── normalize.js # 归一化与相似度 │ ├── engine.js # 匹配与上下文 │ ├── speaker.js # 语音合成与动画同步 │ └── app.js # 入口串起整个流程 └── data/ └── dialogue.json # 对话库剧本启动方式我一般用系统自带的一行静态服务命令或者直接用一个轻量的本地服务工具把目录映射到某个端口。之所以不用双击打开文件前面说过麦克风权限拿不到。移动端调试时注意很多手机浏览器对语音合成有额外限制必须由一次用户点击触发才能出声所以我把开始对话按钮做成了必须点的第一步不能自动播放。4.2 对话引擎核心代码串起来引擎部分的核心就三件事装载剧本、填充槽位、选句渲染。装载的时候做一次预处理把每个节点的 patterns 全部归一化后缓存起来避免每次匹配都重新算。let DB { nodes: [] }; const FALLBACK_ID fallback.generic; const ctx { nodeId: idle, slots: { nickname: 朋友 }, flags: [], lastActive: Date.now() }; async function loadDialogue(url) { const raw await fetch(url).then(r r.json()); DB.nodes raw.nodes.map(n ({ ...n, _norm: (n.patterns || []).map(normalize) })); } function fillSlots(node) { const need node.slots || []; if (need.includes(time)) { ctx.slots.time new Date().toLocaleTimeString(zh-CN, { hour12: false }); } if (need.includes(date)) { ctx.slots.date new Date().toLocaleDateString(zh-CN); } return ctx.slots; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function render(tpl, slots) { return tpl.replace(/\{(\w)\}/g, (_, k) slots[k] ?? ); } function reply(input) { const node match(input, DB.nodes, ctx); fillSlots(node); if (node.next) ctx.nodeId node.next; if (node.flags) ctx.flags.push(...node.flags); ctx.lastActive Date.now(); return { text: render(pick(node.reply), ctx.slots), emotion: node.emotion || calm }; }match函数就是 2.2 节那段注意它接收的是已经带_norm的节点判断包含关系时用_norm而不是原始 pattern否则归一化白做了。这里有个小改动值得加给match传入的ctx里加一个recent队列存最近三次命中的节点 id如果同一个节点连续命中两次以上就强制换一条 reply 里的候选句子避免用户连续问同一个问题时得到一模一样的回答。这个改动只需要三行体验提升非常明显。4.3 把语音、动画、界面串成一条流水线串流程的时候最容易乱我的建议是给整个会话定义一个明确的状态机待机 → 聆听 → 思考 → 播报 → 待机。每个状态下按钮的外观、动画的表现、输入的接收情况都不一样写清楚了就不会互相互相打架。比如播报状态下必须禁用麦克风否则会把自己的声音录进去思考状态下按钮显示为不可点击避免用户连点重复触发。const ui { status: document.getElementById(status), input: document.getElementById(textInput), wave: document.getElementById(wave) }; function setState(s) { ui.status.dataset.state s; ui.wave.dataset.state s; if (s listening) { ui.status.textContent 在听……; } if (s thinking) { ui.status.textContent ……; } if (s speaking) { ui.status.textContent 正在回答; } if (s idle) { ui.status.textContent 点一下开始; } } async function handleInput(text) { if (!text.trim()) return; setState(thinking); const { text: answer } reply(text); setState(speaking); const u speak(answer); u.onend () setState(idle); u.onerror () setState(idle); }onerror那个分支千万别漏。语音合成在某些环境里会因为音色加载失败直接不回调onend界面就永远卡在正在回答用户只能刷新页面。加一行错误处理让状态强制回落是最便宜的保险。4.4 联调时的三个实用检查联调阶段我有三个固定动作每次改完都跑一遍。第一打开控制台看ctx.lastScore把常问的十几个问题挨个说一遍看分数分布低于 0.5 的回去补同义说法高于 0.9 但答错的回去删掉过于宽泛的 pattern。第二把回复文本全部打印出来数一下字数超过 25 个字的直接砍这一步能显著提升听感。第三关掉网络刷新一次确认页面完全离线可用所有资源都本地化这个项目最大的优势之一就是断网照样跑别浪费了。还有一个容易被忽略的地方首屏预热。语音引擎第一次调用时往往有个几百毫秒的初始化开销如果第一次就用在正式对话上用户会觉得第一句特别卡。我的做法是页面加载完成后让引擎悄悄念一个空字符串或者一个极短的音节把引擎唤醒用户点开始时就已经是热的了。5. 常见问题与排查实录5.1 声音、动画、界面不同步怎么破这是出现频率最高的问题表现有三种嘴型动了但没声音、有声音但动画提前结束、声音播完了界面还卡在正在回答。原因基本都出在事件绑定上。第一种通常是动画用setTimeout按估算时长启停而合成实际时长因为标点、数字读法而变动两边对不上改成由onstart、onboundary、onend三个事件驱动就解决了。第二种常见于多条回复合并播放时只监听了最后一条的结束事件需要给每条都挂上或者用一个队列统一管理。第三种就是前面说的缺onerror兜底。现象大概率原因处理方式嘴型在动但没声自动播放策略拦截必须由点击事件触发首次播报声音先结束动画还在动用定时器估算时长改用onend事件收尾界面卡在思考中缺少onerror分支加错误回调强制回落 idle第一句明显延迟语音引擎未预热页面加载时播一个空音节播放中途被截断连续触发了新的播报播报前先调取消方法清空队列最后一条我踩得最惨。用户连续问两个问题两次播报排队但界面状态只记录了一个结果第二句刚开始说就被第三句打断听起来像卡带。解决办法很简单每次调用播报前先清空播放队列再入队宁可丢掉一句旧的也别让两句叠在一起。5.2 答非所问的三类成因答非所问看着像玄学其实可以拆成三类。第一类是归一化没做干净用户说了句带大量标点或英文字母的话pattern 是干净的短句相似度算出来偏低直接被兜底吃掉。排查方法是把归一化前后的文本都打出来对比。第二类是阈值过高脚本量大了以后你把阈值从 0.45 调到 0.6结果一堆原本能命中的问法全掉进兜底。这时候不要一味降阈值正确的是补充 pattern而不是放宽匹配否则会引来更多误命中。第三类是 pattern 太宽泛比如一个节点的 pattern 只写了怎么那用户问什么带怎么的都往这里撞。我的经验是 pattern 长度不要低于两个字最好在三到六个字之间太短的词一律去掉。还有一种隐性问题值得单独说多条节点争抢同一个输入。比如你是谁和你叫什么分别属于两个节点但相似度都在 0.6 左右最终选哪个取决于遍历顺序。为了结果稳定我加了weight字段做二级排序同分时权重高的优先权重也一样时再按节点在文件里的顺序这样每次结果可复现调试才不至于一会儿好一会儿坏。5.3 边界与合规哪些线不能踩这一点我要单独强调因为它决定了这个项目能不能拿出去分享。第一做成娱乐演示可以做成欺骗工具不行。如果这个东西被用来冒充某个真实机构的客服、向他人索取信息那就是另一回事了责任在制作者身上。所以我在所有版本里都保留一个显眼的演示用途标识并且不会去复刻任何真实产品的图标、界面布局和唤醒词。第二声音来源要干净。自己做音色调整、用系统自带的通用音色都没问题但不要去克隆某个真实人物的声音尤其是未经同意的情况下这涉及声音权益很容易出问题。第三不要采集对话数据上传。这个项目的最大优势就是全本地运行别画蛇添足加个后台统计把用户说的话传出去那等于自己给自己找麻烦。我把这几条做成了项目里的一个NOTICE.md每次分享的时候顺手带上看着像多此一举实际上少了很多解释成本。6. 玩法扩展从工具变成作品6.1 人格设定让同样的脚本有两种性格对话库和引擎分离之后最爽的一点是同一套节点可以配出完全不同的人格。我一般用三个维度来定义人格称呼方式、句子长度、情绪词密度。一个稳重型的助手称呼用您句子偏长几乎不用语气词一个跳脱型的称呼用嘿句子控制在十个字以内句尾爱加啦嘛诶。实现上不需要改引擎只需要准备两份 reply 文案或者在渲染时根据emotion标签套一层语气后缀映射表。这个技巧在展示时效果特别好因为观众能立刻感受到性格这种东西的存在而实际上背后只是字符串替换。我做过一个小测试把同一段内容用两种人格各讲一遍让完全不懂技术的朋友听几乎所有人都能说出这个稳重、那个活泼但没人猜到实现方式只是换了几个词。6.2 剧情分支与彩蛋设计单轮问答做腻了之后加点分支会好玩很多。我的做法是用require加next组合出条件跳转只有当用户先问过某个话题才会解锁隐藏的回答。比如先问你喜欢什么颜色助手答蓝色之后再问为什么才会给出那段长回答没问过就直接问为什么只会得到一句你还没问我喜欢什么呢。彩蛋的设计原则是低频但可发现。触发条件不能太苛刻否则永远没人看到也不能太明显否则失去惊喜感。我常用的一类彩蛋是数字触发比如用户连着说三遍你好助手第三次会回一句你已经跟我打了三次招呼了是不是有点紧张。这种设计在视频拍摄时非常出效果因为观众能明显感觉到它有记性。6.3 存档、导出与分享的几个实操建议做到最后一步通常是想分享出去。纯前端的好处是打包简单把整个目录压成一个压缩包就能发人对方起个本地服务就能跑。如果你想做成链接形式直接丢到任意静态托管上就行不需要后端。这里有个小提醒对话库文件不要写死在代码里保持独立的 JSON 文件这样别人拿到之后可以直接改剧本不用碰 JavaScript。我甚至见过有人把对话库做成了可视化的编辑页面左边填问题右边填回答保存后导出成 JSON这个思路很适合做成系列内容。导出的另一个用处是做视频素材。我习惯在录制前把每一轮对话的预期走向列成一张表标清楚哪句是必答、哪句是随机、哪句是兜底拍的时候按顺序走能大幅减少废片率。随机回复的节点记得在录制时多按几次挑一条最顺的。最后分享一个我自己最常用的小技巧在兜底话术里藏一两次反问。比如这句我没写进剧本不过我想先问问你你今天怎么想起来找我的。用户被问回来的时候会短暂地愣一下然后下意识回答这时候你就把主动权拿回来了而且对方往往会觉得这东西挺会聊天的——哪怕它其实只是没听懂。这个小设计的成本是一行字符串收益是整个对话的节奏感我个人认为是这个项目里性价比最高的一个改动。我在实际使用中最大的体会是这类自制对话系统的天花板不在技术而在你写的那些话本身。引擎只能保证说得出来、接得上去至于说得好不好听、有没有意思全看剧本打磨。我前后改过四版对话库代码几乎没动过但体验差距非常大。所以如果你打算动手做建议先花半天时间把想问的十几个问题和想给的回答老老实实写下来再动手写代码顺序反了会白折腾很久。