AI应用(来源:网络)
在AI应用中实时推流是个很实用的功能,可以优化用户体验。试想下,你是想干等个数分钟到最后结果,还是数秒后就开始有数据显示?结果显而易见。OpenAI SDK等都支持推流(stream),只需加一行参数stream: true即可。前端读取传过来的chunk就可以啰。
但是有个问题,现在AI回复的默认格式都是markdown,那要怎么样实时解析markdown格式呢?
有封装好的AI SDK(by Vercel)可以使用,看了下文档,表面简单,但集成起来还蛮复杂的。而且它支持的模型要一个个独立的包,比如@ai-sdk/openai, @ai-sdk/anthropic,每个都要单独安装,有点不便。还不如OpenAI SDK,换个model和baseUrl就可以调用市面上几乎所有的模型!
逐个接受块
OpenAI SDK在后台逐个地将token传过来,前端接受即可。代码如下:
const decoder = new TextDecoder()
let getStream = function (reader) {
return reader.read().then(async function (result) {
// 如果数据已经读取完毕,直接返回
if (result.done) {
clickFlag.value = false
return
}
// 取出本段数据(二进制格式)
let chunk = result.value
let text = decoder.decode(chunk, { stream: true })
// 将本段数据追加到网页之中
messageDiv.innerHTML += text
chatContainer.scrollTop = chatContainer.scrollHeight
// 递归处理下一段数据
return getStream(reader)
})
}
getStream(response.body.getReader())
实时markdown解析
推流式 Markdown 的核心问题是:chunk 是随机切分的,一个 **、代码块围栏、表格行可能被拆到两段里,直接对单个 chunk 渲染会闪烁/解析错乱。正确做法是累积原始文本 + 每次渲染全文 + 对末尾未闭合片段做"保护性截断"。流结束后做最终完整解析,确保所有结构(含最后被截断的部分)都正确渲染。这里用到的markdown解析是@comark/html。以下是代码:
// —— 累积区 ——
let rawText = '' // 累积的全部原始 markdown
let renderTimer = null // 渲染节流
let lastSafeLength = 0 // 上次安全渲染到的长度
/**
* 保护性截断:把末尾可能"半截"的 markdown 结构留给下一段
* 返回可安全渲染的文本长度
*/
function getSafeLength(text) {
const tail = text.slice(-200) // 只检查末尾 200 字符,够覆盖围栏/表格/强调
// 1. 未闭合的代码围栏 ```:找到最后一个 ``` 之后都不渲染
const fenceIdx = tail.lastIndexOf('```')
if (fenceIdx !== -1) {
// 统计该围栏之前 tail 中围栏数量的奇偶,奇数 = 未闭合
const before = tail.slice(0, fenceIdx)
const count = (before.match(/```/g) || []).length
if (count % 2 === 0) {
return text.length - (tail.length - fenceIdx)
}
}
// 2. 未闭合的行内代码 `(奇数个反引号)
const backticks = (tail.match(/(?<!`)`(?!`)/g) || []).length
if (backticks % 2 === 1) {
const idx = tail.lastIndexOf('`')
return text.length - (tail.length - idx)
}
// 3. 未闭合的表格行(| ... | 结尾没有换行,可能行还没收齐)
const lastNl = text.lastIndexOf('\n')
const lastLine = text.slice(lastNl + 1)
if (lastLine.includes('|') && /^\s*\|?.*\|?\s*$/.test(lastLine) && !lastLine.endsWith('\n')) {
// 表格行通常以 | 结尾才完整;先保守不渲染末行
if (!lastLine.trimEnd().endsWith('|')) {
return lastNl + 1
}
}
return text.length
}
/** 节流渲染:避免每个 chunk 都触发一次完整解析 */
function scheduleRender() {
if (renderTimer) return
renderTimer = setTimeout(async () => {
renderTimer = null
const safeLen = getSafeLength(rawText)
if (safeLen <= lastSafeLength) return // 没有新的安全内容
const safe = rawText.slice(0, safeLen)
const s = await renderHtml(safe)
// 把未渲染的尾巴以纯文本追加,避免内容"跳变"
messageDiv.innerHTML = s + escapeHtml(rawText.slice(safeLen))
lastSafeLength = safeLen
chatContainer.scrollTop = chatContainer.scrollHeight
}, 1000) // 1s 节流
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}
/** 结束渲染:对全文做最终解析 */
async function renderFinal() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null }
const s = await renderHtml(rawText)
messageDiv.innerHTML = s
chatContainer.scrollTop = chatContainer.scrollHeight
}
/** 处理推流,主要处理函数 */
let decoder = new TextDecoder() //文本编译
let getStream = function (reader) {
return reader.read().then(async function (result) {
// 数据读取完毕
if (result.done) {
await renderFinal() // 最终完整渲染
return
}
// 取出本段数据并解码
let chunk = result.value
let text = decoder.decode(chunk, { stream: true })
rawText += text // 关键:累积而不是直接 innerHTML +=
scheduleRender() // 节流渲染
// 递归处理下一段数据
return getStream(reader)
})
}
getStream(response.body.getReader())