news 2026/9/11 4:16:26

大模型流式输出前端实现:ReadableStream与性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型流式输出前端实现:ReadableStream与性能优化实战

1. 从“打字机效应”说起:为什么大模型的回答总像在敲键盘?

你有没有试过在 ChatGPT 或国内某款主流大模型网页端提问后,盯着输入框下方那行文字——它不是“唰”一下整段弹出来,而是像老式打字机一样,“嗒…嗒…嗒…”一个字一个字往外蹦?光标在末尾轻轻跳动,新字不断追加,中间还可能卡半秒、顿一下、再继续。这种体验太熟悉了,熟悉到我们几乎忘了问一句:这根本不是“显示延迟”,而是前端刻意设计的“流式输出”行为

很多人误以为这是后端算得慢、网络传得慢,甚至怀疑自己网速不行。其实恰恰相反——流式输出是前端主动选择的、有明确技术目的的交互策略,它背后是一整套协同机制:后端用 SSE(Server-Sent Events)或 ReadableStream 持续推送 token,前端用 JavaScript 实时捕获、拼接、渲染,还要处理中断、重试、格式化、防抖等细节。它不是“凑合能用”的妥协方案,而是当前 LLM Web 应用的标准交付形态

我第一次在项目里实现这个功能时,也踩过典型误区:直接把整个 response.body 当作一次性字符串去.text(),结果页面白屏三秒才刷出全文;后来改用response.body.getReader(),又因为没处理done === true的边界条件,导致最后一段文字总丢掉;再后来加了 loading 状态,却发现用户点击“停止生成”后,UI 还在疯狂追加字符……这些都不是框架 bug,而是对流式本质理解不到位的必然代价。

关键词里反复出现的SSE、ReadableStream、前端、大模型,其实指向同一个底层事实:LLM 的推理输出天然就是逐 token 生成的(token 可能是字、词或子词),而 Web 浏览器无法原生消费这种“持续涌出”的数据流。我们必须在前端架起一座桥——一边对接后端的流式响应,一边对接 DOM 的增量更新。这座桥的每一块砖,都得亲手砌。

所以这篇文章不讲“怎么调 API”,也不堆砌概念定义。我要带你从一次真实请求出发,拆开浏览器控制台 Network 面板里那个event-stream请求,看清楚:

  • 后端发来的到底是什么格式的数据?
  • 前端 JS 是如何“听”到每一个新 token 的?
  • 为什么用fetch + ReadableStream而不是XMLHttpRequest
  • 渲染时怎么避免频繁 reflow 导致的卡顿?
  • 用户点击“停止”时,真正的中断点在哪里?

这不是理论推演,而是我在三个不同大模型平台(含自研私有部署)上线流式输出功能时,逐行调试、反复验证、最终沉淀下来的实操路径。下面,我们就从最基础的协议层开始。

2. 协议层真相:SSE 不是唯一选择,但 ReadableStream 才是现代前端的标配

先破一个常见误解:“大模型流式输出 = SSE”。很多文章一上来就教你怎么写EventSource,仿佛这是唯一正解。但现实是:SSE 在 LLM 场景中已逐渐退居二线,ReadableStream + fetch 才是当前主流框架(React/Vue/Svelte)的实际首选。原因很实在——不是技术优劣,而是兼容性、可控性和调试便利性的综合权衡。

2.1 SSE 的原始模样:文本流里的 event:data 分隔符

SSE 协议本身非常朴素:服务端返回Content-Type: text/event-stream,然后按行发送带前缀的数据块。一个典型的 LLM 流式响应长这样:

event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"今"},"index":0,"finish_reason":null}]} event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"天"},"index":0,"finish_reason":null}]} event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"天"},"index":0,"finish_reason":null}]} event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"气"},"index":0,"finish_reason":null}]} event: message data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"真"},"index":0,"finish_reason":null}]}

注意几个关键点:

  • 每个data:行后面必须跟一个空行(\n\n),这是 SSE 的分隔标识;
  • event:字段可选,但多数 LLM API(如 OpenAI 兼容接口)会固定设为message
  • data:后面是 JSON 字符串,需手动JSON.parse()
  • EventSource自动重连,但无法主动关闭连接eventSource.close()有效,但无法取消正在接收的 chunk);
  • SSE 不支持自定义请求头(比如 Bearer Token 鉴权),只能靠 URL 参数或 cookie,这对需要严格鉴权的大模型服务是硬伤。

提示:如果你的后端强制要求 SSE(比如某些老旧网关只透传 SSE),那EventSource确实是唯一选择。但务必注意:EventSourceonerror回调无法区分“网络断开”和“服务端主动关闭”,且重连间隔不可控(默认 3 秒),容易造成用户感知上的“卡顿假象”。

2.2 ReadableStream:fetch 的隐藏能力,才是流式输出的现代基石

从 Chrome 75、Firefox 65 开始,fetch返回的Response.body就暴露了getReader()方法,允许我们以流式方式读取响应体。这才是目前绝大多数大模型前端 SDK(如@anthropic-ai/sdkopenai-js)默认采用的方式。它的结构更清晰、控制更精细:

const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer sk-xxx' // ✅ 支持任意 header }, body: JSON.stringify({ messages: [...] }) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); // 关键:获取流式 reader const reader = response.body?.getReader(); if (!reader) throw new Error('ReadableStream not supported'); let accumulatedText = ''; while (true) { const { done, value } = await reader.read(); if (done) break; // value 是 Uint8Array,需转为字符串 const chunk = new TextDecoder().decode(value); // 注意:chunk 可能包含不完整 JSON(如 {"delta":{"content":"今"}) // 必须累积、按换行符分割、逐条解析 accumulatedText += chunk; const lines = accumulatedText.split('\n').filter(l => l.trim() !== ''); for (const line of lines) { try { const parsed = JSON.parse(line); // 处理单个 token:parsed.choices[0].delta.content handleToken(parsed.choices[0].delta.content); // 清空已处理部分 accumulatedText = accumulatedText.substring(line.length + 1); } catch (e) { // JSON 解析失败,说明是不完整行,等待下一批 continue; } } } reader.releaseLock();

这段代码揭示了 ReadableStream 的核心优势:

  • 完全可控的生命周期reader.read()是 Promise,可随时awaitbreakreader.releaseLock()显式释放;
  • 支持任意 HTTP 方法和 Header:鉴权、trace-id、custom metadata 全部自由携带;
  • 错误可捕获reader.read()抛出异常时,你能精确知道是网络中断还是服务端 EOF;
  • 内存友好Uint8Array直接操作二进制,避免字符串拼接的 GC 压力(尤其长文本场景)。

但代价也很明显:你需要自己处理分块粘包问题。HTTP 流没有内置消息边界,后端可能把两个 JSON 对象塞进同一个value,也可能把一个 JSON 拆成两段发。这就是为什么代码里要用accumulatedText缓存+按\n切分——因为主流 LLM API(OpenAI/Anthropic/Ollama)约定:每个 token 对应一行 JSON(即 NDJSON 格式)。

注意:NDJSON(Newline-Delimited JSON)不是标准,而是行业默契。它要求每行是一个独立、合法的 JSON 对象,行与行之间用\n分隔。这意味着你不能依赖JSON.parse(chunk),而必须按行解析。这也是为什么很多初学者写的流式代码会漏字或乱码——他们直接对整个chunk调用JSON.parse,却忽略了chunk可能包含半个 JSON。

2.3 协议选型决策树:什么情况下该用 SSE?什么必须用 ReadableStream?

我们团队在给客户做私有大模型平台时,曾做过 AB 测试:同一套后端,分别提供 SSE 和 ReadableStream 接口,前端用相同逻辑渲染。结果如下(样本量 12,000 次请求):

指标SSE 方案ReadableStream 方案差异分析
首字节时间(TTFB)124ms ± 18ms119ms ± 15ms基本无差异,取决于网络
完整响应耗时3.2s ± 0.7s3.1s ± 0.6s可忽略
中断响应延迟(用户点停止)820ms ± 210ms140ms ± 45msReadableStream 胜出reader.cancel()立即生效,SSE 需等当前 event 完成
移动端兼容性(iOS Safari)iOS 12.2+ 支持iOS 14.5+ 支持SSE 更老,但 iOS 14.5 已覆盖 99% 用户
鉴权灵活性❌ 仅支持 URL 参数/Cookie✅ 完整 Header 支持ReadableStream 必选
调试便利性Network 面板显示为event-stream,内容需手动复制Network 面板显示为fetch,可直接查看Response.bodyReadableStream 更直观

结论很明确:除非你的目标用户必须兼容 iOS < 14.5,否则一律选用 ReadableStream + fetch。SSE 的“自动重连”在 LLM 场景反而是负担——用户提问后不会希望系统自动重试,而是明确看到“生成失败”并手动重试。而 ReadableStream 的显式控制,让“停止生成”、“重试”、“超时降级”等交互变得可预测、可测试。

3. 前端渲染层:为什么不能直接 innerHTML += ?DOM 更新的性能陷阱

协议层搞定了,数据流能稳定进来,下一个坑就在 DOM 渲染。很多开发者第一反应是:既然拿到一个字,那就element.innerHTML += char——简单粗暴,立竿见影。但实测下来,在 60fps 的屏幕刷新率下,这种写法会让长文本生成过程明显卡顿,甚至触发浏览器的“脚本运行太久”警告

为什么?因为innerHTML +=触发的是全量重排(reflow)+ 重绘(repaint)。每次执行,浏览器都要:

  1. 解析新增的 HTML 字符串;
  2. 构建新的 DOM 子树;
  3. 计算所有元素的几何位置(layout);
  4. 绘制像素到屏幕(paint);
  5. 如果父容器有 CSS 动画或 transition,还要触发合成层切换……

这个过程在单次操作中可以接受,但当每秒涌入 10~20 个 token(即每 50~100ms 更新一次),累计 100 次操作后,主线程就被彻底占满。我用 Chrome Performance 面板录过一段 30 秒的流式生成过程:innerHTML +=方案下,Layout 事件占比高达 42%,而优化后的方案降到 6%。

3.1 正确姿势:Text node + appendChild,绕过 HTML 解析

最优解是完全避开 HTML 解析,直接操作文本节点。原理很简单:LLM 输出的 token 几乎全是纯文本(偶尔有\n换行,极少含<>符号),我们不需要innerHTML的富文本能力,只需要“追加字符”。

// 初始化:创建一个空的 text node const textNode = document.createTextNode(''); const container = document.getElementById('output'); container.appendChild(textNode); // 每收到一个 token,直接 append 到 text node function appendToken(char: string) { textNode.appendData(char); // ✅ 无 layout 触发,极快 } // 处理换行:\n 不是 HTML 换行,需转为 <br> function appendNewline() { // 先断开 text node container.removeChild(textNode); // 插入 <br> const br = document.createElement('br'); container.appendChild(br); // 创建新 text node 继续 const newText = document.createTextNode(''); container.appendChild(newText); textNode = newText; }

document.createTextNodetextNode.appendData是 DOM Level 1 就存在的 API,性能极高。appendData本质是字符串拼接,不触发任何样式计算或布局。实测:在 2023 款 MacBook Pro 上,每秒调用 1000 次appendData,主线程占用率 < 1%。

但要注意一个细节:appendData不会自动处理 HTML 实体。如果 token 里真的包含<>&(虽然 LLM 很少输出,但安全起见),你需要提前转义:

function escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; // 利用浏览器内置转义 } // 或者更轻量: function escapeHtml(text: string) { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); }

3.2 防抖与批量更新:别让每一帧都忙死

即使用了appendData,高频更新仍可能造成问题。浏览器每秒最多重绘 60 次(60fps),如果每 50ms 收到一个 token,意味着每秒 20 次 DOM 更新,看似不多,但叠加其他 JS 任务(如滚动监听、动画帧),很容易挤占主线程。

解决方案是微任务级别的防抖:不追求“实时”,而追求“流畅”。我们把 token 缓存在数组里,每 16ms(≈1帧)统一 flush 一次:

let tokenBuffer: string[] = []; let isFlushing = false; function queueToken(token: string) { tokenBuffer.push(token); if (!isFlushing) { isFlushing = true; queueMicrotask(flushTokens); } } async function flushTokens() { if (tokenBuffer.length === 0) { isFlushing = false; return; } // 批量追加 const batch = tokenBuffer.join(''); textNode.appendData(batch); tokenBuffer = []; isFlushing = false; }

queueMicrotask是关键:它确保在当前 JS 任务结束后、下一次渲染帧之前执行flushTokens,既避免了setTimeout(fn, 0)的宏任务调度开销,又保证了更新节奏与屏幕刷新同步。实测帧率从 42fps 提升至 58fps。

提示:不要用requestAnimationFrame做这个防抖!RAF 是为动画设计的,它的触发时机受屏幕刷新率影响,且在页面后台时会暂停。而流式输出是“数据驱动”,必须在前台/后台都稳定工作。queueMicrotask是唯一正确选择。

3.3 样式与光标:让“打字机效果”真正可信

纯文本追加解决了性能,但用户体验还差一口气——缺少“正在输入”的视觉反馈。用户需要知道:“它没卡住,还在干活”。这就需要模拟光标闪烁。

最简单的做法是给容器加::after伪元素:

#output { position: relative; /* 其他样式 */ } #output::after { content: '|'; animation: blink 1s infinite; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

但问题来了:当生成结束,光标应该消失。如果直接display: none,CSS 动画会突兀终止。更好的方案是用 JS 控制光标状态

let cursorActive = true; function showCursor() { cursorActive = true; outputElement.classList.add('cursor-active'); } function hideCursor() { cursorActive = false; outputElement.classList.remove('cursor-active'); } // 在 flushTokens 后检查是否结束 function flushTokens() { // ... 批量追加逻辑 if (isComplete) { // 由后端 finish_reason 判断 hideCursor(); } }
.cursor-active::after { content: '|'; animation: blink 1s infinite; }

这样,光标只在生成中显示,结束时平滑消失。更重要的是,你可以在此基础上扩展:比如生成暂停时,光标变成;错误时变成⚠️;甚至根据 token 类型(代码块、列表项)动态切换光标样式。这才是专业级的交互细节。

4. 中断与错误处理:用户点“停止”时,究竟发生了什么?

流式输出最被低估的环节,不是“怎么开始”,而是“怎么优雅结束”。用户点击“停止生成”按钮,你以为只是reader.cancel()就完事了?不。这背后涉及四层中断:前端 UI 层、JS 执行层、网络传输层、后端推理层。任何一层没处理好,都会导致“按钮点了,但文字还在蹦”。

4.1 四层中断链:从 UI 点击到 GPU 停止

我们以一个典型私有大模型部署(Ollama + FastAPI)为例,追踪一次“停止”操作的完整链路:

层级触发动作前端责任后端责任常见失败点
UI 层用户点击“停止”按钮禁用按钮、显示“已停止”状态按钮未置灰,用户重复点击
JS 层调用reader.cancel()捕获AbortError、清理 state未监听cancel事件,reader 泄漏
网络层浏览器发送 TCP RST 包检测 socket 断开Nginx 默认 60s keepalive,RST 后仍发数据
推理层后端终止模型 forward调用torch.cuda.empty_cache()model.generate(..., stop=True)模型未设stop_token_ids,继续生成直到 max_length

重点看网络层。很多团队卡在这里:前端reader.cancel()后,Network 面板里请求状态仍是pending,后端日志却显示“connection closed”。这是因为:

  • reader.cancel()会触发浏览器关闭 underlying socket;
  • 但服务端(尤其是用 Gunicorn/Uvicorn 的 Python 后端)默认不会立即感知 socket 断开,而是继续向已关闭的 socket 写数据,直到write()系统调用返回EPIPE
  • 这期间,模型仍在 GPU 上跑,显存不释放,CPU 空转。

解决方案是后端主动心跳检测。我们在 FastAPI 的流式 endpoint 加入:

from starlette.responses import StreamingResponse import asyncio async def stream_response(): # ... 初始化模型 async def generate(): for token in model_stream: yield f"data: {json.dumps(token)}\n\n" # 关键:每发一个 token,检查 client 是否还活着 try: # 尝试写入一个小数据包探测 await asyncio.sleep(0.001) # 避免阻塞 # 如果 client 断开,这里会抛异常 except asyncio.CancelledError: logger.info("Client cancelled stream") break except Exception as e: logger.warning(f"Client disconnect: {e}") break return StreamingResponse(generate(), media_type="text/event-stream")

同时,Nginx 配置必须调整:

location /api/chat { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 关键:缩短超时,让断连更快被感知 proxy_read_timeout 10; # 从默认 60s 降到 10s proxy_send_timeout 10; }

4.2 前端中断的完整代码模板

基于以上分析,一个健壮的流式请求函数应该长这样:

interface StreamOptions { abortController?: AbortController; // 外部传入,便于统一管理 onToken?: (token: string) => void; onComplete?: () => void; onError?: (error: Error) => void; } async function streamChat( input: ChatInput, options: StreamOptions = {} ) { const controller = options.abortController || new AbortController(); const signal = controller.signal; try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal, // ✅ 传递 abort signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error('ReadableStream not supported'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = new TextDecoder().decode(value); buffer += chunk; // 按行解析 NDJSON const lines = buffer.split('\n').filter(l => l.trim()); for (const line of lines) { try { const data = JSON.parse(line); const token = data.choices?.[0]?.delta?.content || ''; if (token) { options.onToken?.(token); } if (data.choices?.[0]?.finish_reason) { options.onComplete?.(); break; } } catch (e) { // 忽略不完整 JSON,保留 buffer continue; } } // 清空已处理行 buffer = buffer.substring(buffer.lastIndexOf('\n') + 1); } reader.releaseLock(); } catch (error) { if (signal.aborted) { // ✅ 用户主动中断 console.log('Stream aborted by user'); options.onError?.(new Error('User aborted')); } else if (error instanceof TypeError && error.message.includes('fetch')) { // ✅ 网络错误 options.onError?.(new Error('Network error')); } else { // ✅ 其他错误(如 JSON parse fail) options.onError?.(error as Error); } } } // 使用示例 const abortController = new AbortController(); // 开始生成 streamChat({ messages }, { onToken: (t) => appendToken(t), onComplete: () => hideCursor(), onError: (e) => showError(e.message), abortController, }); // 用户点击停止 document.getElementById('stop-btn')!.addEventListener('click', () => { abortController.abort(); // ✅ 触发全链路中断 });

这个模板的关键在于:

  • AbortController作为中断信令中心,贯穿 fetch 和 reader;
  • signal传给 fetch,确保网络层中断;
  • reader.read()catch捕获AbortError,确保 JS 层中断;
  • onCompleteonError回调分离,状态清晰;
  • buffer管理严格,避免粘包丢失。

注意:abortController.abort()会同时触发 fetch 的AbortErrorreader.read()AbortError,但它们是同一个信号源。不要重复调用reader.cancel(),否则会报错TypeError: reader has been canceled

5. 实战避坑指南:那些文档里不会写的 7 个致命细节

最后,分享我在三个大模型项目中踩过的、文档绝不会提、但线上必现的 7 个细节。它们不难,但缺一不可:

5.1 细节 1:TextDecoder 的编码陷阱

new TextDecoder().decode(value)默认是 UTF-8,但某些后端(尤其 Windows 部署的旧版服务)可能返回 GBK 编码。现象是:中文变成乱码 ``。解决方案不是猜编码,而是强制指定 UTF-8 并忽略错误

const decoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true }); // fatal: false → 遇到非法字节不抛错,替换为 // ignoreBOM: true → 忽略 UTF-8 BOM 头(EF BB BF)

5.2 细节 2:移动端键盘遮挡输出区

iOS Safari 下,当软键盘弹出,position: fixed的输出容器会被顶上去,用户看不到最新 token。解决方法是监听resize事件,动态调整bottom

let originalBottom = '10px'; window.addEventListener('resize', () => { if (window.visualViewport?.height < window.innerHeight * 0.7) { // 键盘弹出,提升输出区 outputElement.style.bottom = '20vh'; } else { outputElement.style.bottom = originalBottom; } });

5.3 细节 3:Safari 对 fetch + stream 的兼容性补丁

Safari 16.4+ 才完全支持ReadableStream,但早期版本(15.4~16.3)存在reader.read()返回undefined的 bug。必须加兜底:

const { done, value } = await reader.read(); if (done || !value) break; // Safari 兜底

5.4 细节 4:长 token 的截断风险

LLM 有时会一次性返回多个 token(如 emoji👍是 4 字节,但JSON.parse后是 1 个字符)。如果前端按字节切分,会导致 emoji 显示为 ``。永远用String.fromCodePoint()处理 Unicode:

function safeAppend(char: string) { // 确保 emoji、CJK 字符完整 for (let i = 0; i < char.length; i++) { const code = char.codePointAt(i); if (code != null && code > 0xFFFF) { // 高位代理对,需一起取 const next = char.codePointAt(i + 1); if (next != null && next >= 0xDC00 && next <= 0xDFFF) { textNode.appendData(String.fromCodePoint(code, next)); i++; // 跳过下一个 continue; } } textNode.appendData(String.fromCodePoint(code ?? 0)); } }

5.5 细节 5:服务端 idle timeout 的应对

热搜词里提到的before completion: idle timeout waiting for sse,本质是服务端设置了timeout=30s,但前端因网络抖动,两次reader.read()间隔超过阈值。解决方案是前端主动心跳:在while(true)循环里,每 25s 发送一个空行data:\n\n保活:

let lastActivity = Date.now(); while (true) { const { done, value } = await reader.read(); if (done) break; lastActivity = Date.now(); // ... 处理 value // 每 25s 主动保活 if (Date.now() - lastActivity > 25000) { // 发送保活 ping(需后端支持) await fetch('/api/ping', { signal }); } }

5.6 细节 6:SEO 与 SSR 的矛盾

流式输出是纯客户端行为,SSR 渲染时output容器为空,搜索引擎爬虫看到空白页。解决思路是:服务端预渲染首句。后端在流式开始前,先同步返回{"first_sentence":"今天天气真好"},前端用它填充初始内容,再开启流式:

<!-- SSR 输出 --> <div id="output">今天天气真好</div> <script> // 客户端 JS 检测是否已有内容,有则追加,无则新建 const output = document.getElementById('output'); const hasInitial = output.textContent.trim() !== ''; if (hasInitial) { textNode = document.createTextNode(''); output.innerHTML = ''; output.appendChild(textNode); } </script>

5.7 细节 7:无障碍访问(a11y)的缺失

屏幕阅读器无法感知textNode.appendData的动态变化。必须添加aria-live="polite"aria-atomic="true"

<div id="output" aria-live="polite" aria-atomic="true"></div>

aria-live="polite"确保阅读器在空闲时播报新内容;aria-atomic="true"让每次更新都作为完整句子播报,而非逐字。否则视障用户听到的是“今”“天”“天”“气”“真”……完全无法理解。

这 7 个细节,每一个都来自线上事故的复盘。它们不炫技,但决定了你的流式功能是“能用”,还是“好用”,甚至是“合规可用”。大模型前端开发,拼的从来不是多酷的算法,而是这些藏在毛细血管里的确定性。

我在上海交大带学生做“动手学大模型”实践课时,常强调一句话:LLM 的魔力在千亿参数,但用户体验的尊严,在每一个被正确处理的换行符、每一个被及时释放的 reader、每一个被无障碍播报的 token。当你把“一个字一个字蹦出来”这件事,从玄学变成可测量、可调试、可交付的工程模块,你就真正跨过了大模型应用的第一道门槛。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 4:13:25

制造企业从传统报表到大数据分析的转型路径

我经常听到制造型企业的管理者说一句话&#xff1a;“报表不是没有&#xff0c;但总觉得差点意思。”工厂里ERP能导出库存报表&#xff0c;MES能拉出产量报表&#xff0c;财务部每个月还能做出一沓经营分析&#xff0c;但真碰上“设备为什么连续两周频繁停机”“这批产品报废率…

作者头像 李华
网站建设 2026/9/11 4:12:15

树莓派Pico ADC深度解析:校准、驱动与硬件定时实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 4:09:46

软件生命周期与过程模型:系统分析师视角的实战拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华