泰文用一个空格结束一个句子。句子内部的分句之间也用同样的空格隔开,普通文章里则根本没有句号。语音智能体在语言模型写回复的同时,把回复流式发送到语音合成的 WebSocket,就必须自己判断一句话在哪里结束、下一句从哪里开始。在英语里它会等待的那个字符,在泰语里永远不会出现。
这篇指南讨论的就是这个判断:实时 WebSocket 如何处理一串泰语文本流,泰语回复里带有哪些边界,在哪里断开会听起来不对。最后给出一种办法,让语言模型自己标出断句的位置。
空格承担了句号的作用#
书面泰语用同一个字符做两件事。空格结束一个句子,也结束句子内部的一个分句。逗号出现在数字和列举中,问号在聊天里比在印刷品里常见,句号则只出现在缩写之后,其他地方几乎见不到。读者靠空格两边的词,来区分这里是句子的边界还是分句的边界。
学校里教的空格规则规定:完整的句子之后留空格,แต่ 或 เพราะ 这类连词之前留空格,数字和引语两侧也留空格。列举的各项之间同样用空格隔开。一段写得好的泰文,是一串以空格隔开的短语,其中的句子边界没有任何专属的标记。
一旦文本要边到达边朗读,这就成了问题。处理英语的流式代码会等句号,处理泰语的流式代码却无从等起。
为什么边界决定了延迟#
实时语音智能体在语言模型生成文本的同时,把文本发送到语音合成的 WebSocket,WebSocket 则分段合成。缓冲区里有了值得朗读的内容,一段就开始合成。听者听到第一段的时间,是模型写完第一句再加上一次合成的时间,此后的每个边界,都是声音换气的地方。
边界选错,会出两种问题。切得太早,一段只说出半个分句就停下,剩下的部分再以重新起调的音高说出来,听起来像是在犹豫。切得太晚,这一段就要等模型还在写的文本,第一个词之前的沉默会随着模型每多写一个字符而变长。
WebSocket 在三种情况下开始合成:帧中出现换行,缓冲区末尾是句末标点,或者缓冲区长度达到阈值。英语对话每遇到一个句号就触发第二种情况。泰语对话前两种都触发不了,只能靠长度阈值,这对一个段落合适,对只有一句话的回复就来得太晚。API 参考文档说明了这三种触发条件,以及手动强制合成一段的 flush 帧。
对话中的泰语句子在哪里结束#
口语泰语比书面泰语更清楚地标出一句话的结束。助手的回复以句末语气词收尾的情况,远比一段散文多。ครับ 和 ค่ะ 用来结束礼貌的陈述句,คะ 和 ครับ 用来结束礼貌的疑问句,นะ、สิ 和 เถอะ 用来缓和语气或催促,ล่ะ、ไหม 和 มั้ย 用来提问。语气词后面跟一个空格,是泰语文本流里最强的边界信号。
- 后面跟空格的句末语气词:ค่ะ ครับ คะ นะคะ นะครับ สิ เถอะ ล่ะ ไหม มั้ย หรือเปล่า。在礼貌的回复里,几乎每个句子都以它们结尾。
- 换行。只要提出要求,语言模型就会写出换行,WebSocket 遇到换行就开始合成,客户端不需要任何逻辑。
- 位于 แต่ แล้ว ส่วน ถ้า เพราะ ดังนั้น 这类句首词之前的空格。这里至少是一个分句边界,在较长的回复里是安全的停顿点。
- 后面跟空格的右引号或右括号。
语气词承载着回复的语体,关于礼貌用语的文章解释了为什么正式的助手几乎每句话都以语气词结尾。正是这个习惯,让语气词规则变得可靠。被要求写礼貌泰语的助手,会自己产生边界。
在哪里断开会听起来不对#
空格是一个停顿,脚本写作指南讲了脚本如何使用空格。段与段之间的边界是一个更长的停顿,音高还会重新起调,有些位置说话人绝不会在那里停。
- 句末语气词之前。在 ค่ะ 之前触发合成,会让这个语气词单独成为一段,隔了一下才孤零零地说出来。
- 数字、金额或时间的中间。๒,๕๐๐ บาท 和 14:30 น. 都要作为一个整体连着读。
- 称谓和名字之间,比如 คุณ สมชาย,或者名字和紧随其后的语气词之间。
- 连词之后。以 แต่ 结尾的一段会悬在半空,直到下一段到来。
- 隔着分句空格的动词和宾语之间,读者在这里会一口气读下去。
简短的回复就是一段。把一个十二个词的回答拆成三段,不会多花积分,也换不来更低的延迟,因为第一段已经让声音开始了。
让模型自己写出断句#
成本最低的边界检测器,就是语言模型本身。指示它每句话以换行结尾,每行只写一到两个分句。WebSocket 遇到换行就开始合成,每段正好是一句话,客户端完全不需要针对泰语的逻辑。
客户端仍要在每轮结束时发送一个 flush 帧,因为模型停止时,最后一句可能没有换行。对空缓冲区执行 flush 是安全的空操作,flushed 事件会确认缓冲区已经清空。
模型在长回答里没有遵守指示时,仍会碰到长度阈值;想更早触发合成的客户端,可以把上面的语气词规则当作后备方案。先检查语气词,再检查它后面的空格,绝不要只看语气词。引语中间的 ค่ะ 是引述话语里的语气词,真正说明句子已经结束的是空格。
插话的代价#
有人在回复播放时插话。客户端发送 cancel。正在朗读的那一段停止,它的费用保留,因为这段文本在插话到达之前已经合成完毕。缓冲区里尚未开始的文本从未计费,会被丢弃。连接保持打开,下一个 text 帧开始新的一段。实时流式指南逐步讲解了这一交互过程。
每段只发一句,插话的代价就限定在正在朗读的那一句上。无论文本分成多少段,积分都按字符计算,0.1 积分的最低收费每个连接只计一次。每一段还会按一次请求计入套餐的速率限制,每个词之后都触发合成的客户端会白白耗掉这个限额,听者却听不出任何改善。
一轮回复,逐帧拆解#
下面的记录是助手的一轮回复。前两句以模型写出的换行结尾,WebSocket 在每句到达时立即合成。第三句到达时没有换行,由客户端在这一轮结束时发送的 flush 收尾。
client text ได้เลยค่ะ\n server charged leg 1 server audio ... client text ร้านเปิดเก้าโมงเช้า client text ถึงสองทุ่มค่ะ\n server charged leg 2 server audio ... client text วันอาทิตย์ปิดนะคะ client flush server charged leg 3 server audio ... server flushed
每个 charged 事件都是一段的收据。模型还在写营业时间时,听者已经听到了 ได้เลยค่ะ,流式发送回复的意义正在于此。