普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月15日首页

智能体与工作流:从「想做一个应用」到「能跑通一条链」

作者 颜酱
2026年4月15日 18:00

智能体与工作流:从「想做一个应用」到「能跑通一条链」

这篇博客用睡前故事串起两件事:概念上分清智能体与工作流;操作上Coze 搭画布(大模型 / 循环 / 插件)、发布工作流,再 创建对话智能体 把链挂上去并用预览验收;最后对比 Coze 与 Node.js 自研。配图已上传 OSS(与本地 assets/agent-workflow/*.png 同源文件名,便于你用 img 脚本覆盖更新)。

本文结构(按需跳读)

部分 内容
概念与需求 智能体、工作流、为何不能一次调模型、串行链、mermaid
设计四步 只在脑子里/文档里「生成」草图,不涉及 Coze 点击路径
实践一 Coze 工作流画布:节点类型、从「开始」到「结束」的配置与截图
实践二 发布工作流创建智能体 → 编排里 挂工作流 → 预览与 发布智能体(截图)
收尾 Coze 优缺点、与自研关系、全文小结

智能体是什么:不止「和大模型聊一句」

智能体(Agent)在经典定义里,是能感知环境做决策再行动的系统。落到今天的大模型应用上,可以把它理解成:

以模型为「大脑」之一,再叠上检索、工具调用、业务规则、多模态输出等能力,按固定或可变策略运转,最终对用户给出一个完整结果单元(而不只是一段即时回复)的那一层产品形态。

用户输入、系统提示词、中间调用的搜索与 TTS 等,都是「环境」与「指导信息」;智能体要做的,是在这些约束下把多步事情办完。


工作流是什么:把能力排成一条(或多条)流水线

智能体里真正承担业务骨架的,是 工作流(Workflow):把「分析意图 → 查资料 → 写稿 → 润色 → 念出来」这类步骤,变成可执行、可观测、可迭代的节点图。

  • 串行工作流:上一步输出是下一步输入,适合故事生成这类主线清晰的任务。
  • 并行与分支:实际业务里常有「同时查多个源」「某步失败则降级」等,图会变复杂;入门阶段先把一条串行链画清楚,价值最大。

一句话:智能体是「产品视角」的说法,工作流是「工程视角」的实现方式。


用「6~8 岁睡前故事」理解:为什么不能只调一次文本模型

假设产品需求是:

  1. 用户给一个故事主题;尽量讲经典民间故事,没有经典则围绕主题创作
  2. 内容与语言要符合 6~8 岁认知,不「超龄」。
  3. 最后用亲切的语音把故事念出来。

若只做 chat.completions 一次调用,模型既可能胡编典故,又无法引用可靠原文,更没有声音。因此这个应用本质上需要多能力组合:

能力 作用
搜索 / 检索 找到故事原文或参考资料(常与 RAG / 检索增强生成 一起讨论)
写作与润色 在检索结果上写草稿,再按儿童口吻改写
语音合成(TTS) 把定稿文本变成可播放音频

这些能力不会自动长在一起,要靠你在产品里编排顺序、约定每步输入输出。这就是工作流要解决的问题。


核心工作流长什么样(串行示例)

把上面的需求压成一条链,可以是:

输入主题 → 生成检索 query → 搜索并整理材料 → 撰写草稿 → 语言与风格润色 → 语音合成 → 输出(文本 + 音频)

用流程图表示更直观:

flowchart LR
  A[用户主题] --> B[生成搜索 query]
  B --> C[搜索 / 整理]
  C --> D[写草稿]
  D --> E[儿童向润色]
  E --> F[TTS]
  F --> G[文本 + 语音]

实现顺序上的建议:先在纸上或文档里画出这条链,标清每一步的输入输出数据结构;再决定用 Coze 拖拽,还是用代码(例如 Node.js)写「调度器」。顺序对了,换工具只是换壳。


设计阶段的四步清单(还不打开 Coze 也能做)

你可以把下面四步当作任意业务的模板,先在文档或白板完成;它们回答的是「做什么」,而不是「在 Coze 里点哪个菜单」:

  1. 定义成功态:用户最终拿到什么?(一段 JSON、一篇带出处的文章、一条语音……)
  2. 拆能力:需要模型、搜索、数据库、支付、TTS 中的哪几项?哪些可以合并成一步?
  3. 定依赖与顺序:哪一步必须等上一步结束?哪一步可以并行?失败时是否重试或降级?
  4. 选承载:原型期用 Coze 等低代码快速验证;上线前再评估是否迁到 自研编排(数据隐私、细粒度调试、成本结构)。

做到这里,你已经「生成」了智能体的设计稿。接下来两节是落地:先在 Coze 里把 工作流画布 跑通,再 发布挂到智能体


实践一:在 Coze 里搭「睡前故事」工作流(画布)

扣子 Coze 提供了工作流编排:用节点把大模型、循环、插件等连成图,适合快速验证「这条链跑不跑得通」。下面配图来自同一套「睡前故事」示例画布,界面以你当前 Coze 版本为准;若菜单文案略有差异,对照节点职责即可。

节点类型与添加路径(和本文截图一致)

在 Coze 工作流画布上点 「添加节点」 时,可按下面方式选类型(不同版本菜单层级可能微调,核心是节点类型要对):

画布上的职责 添加节点时的选择 说明
开始 / 结束 无需添加 新建工作流后画布默认自带;只需配置入参、出参。
生成 query、撰写草稿、润色 大模型 三处都是「大模型」节点,分别改节点标题与提示词、输入输出即可。
搜索并整理内容(外层) 循环 先加循环节点,再在循环体内部加搜索用的插件节点。
循环体内的搜索 插件 → 必应搜索 每次迭代用当前 query 调必应,把结果汇总给后续大模型。
语音合成 插件 → 搜索文本转语音 将润色后的正文交给插件生成音频(插件名以控制台为准)。

下文按数据从左到右的顺序讲配置要点;你在菜单里选对的节点类型,就和「一步步实现」对上了。

画布总览:一条从主题到「文本 + 语音」的链

整体从左到右大致是:开始(默认)→ 生成 query(大模型)→ 搜索并整理内容(循环,循环体内必应搜索)→ 撰写草稿(大模型)→ 润色(大模型)→ 语音合成(插件:搜索文本转语音)→ 结束(默认)。多模态输出在「结束」节点里一次性返回给上层 Bot 或 API。

Coze 工作流画布总览:睡前故事智能体

1. 新建工作流

进入 Coze 控制台 → 工作空间资源库 → 新建 工作流,例如命名为 bedtime_story,描述写清「给 6~8 岁孩子讲睡前故事」。进入画布后,「开始」与「结束」是默认节点,不必在「添加节点」里再选一次;后面所有节点都是从「开始」往后串、最后收进「结束」。

「开始」节点:声明工作流对外的入参。示例里只暴露一个字符串 input(故事主题),后续大模型节点通过模板变量 {{input}} 引用。

开始节点:配置入参 input

2. 第一个大模型节点:从主题到「检索 query」

添加节点 → 大模型,将节点标题改为「生成 query」(名称可自定)。用于:根据用户输入分析意图,并输出一组搜索用 query(后续由循环消费)。

系统提示词可围绕「目标 + 分析方法 + 任务」来写,例如(节选思路):

  • 若主题是常见民间故事名,则生成便于检索原文的 query;
  • 否则结合文化背景生成能搜到参考资料的 query;
  • 明确输出格式要求(如字符串数组)。

用户提示词里使用 Coze 的模板变量,把「开始」节点的输入接进来,例如:

{{input}}

双花括号中的名字需与开始节点里定义的输入字段名一致(默认常为 input)。

输出变量建议配置两个(示例命名):

输出名 类型 含义
querys 字符串数组 多条检索 query
intent 字符串 对用户意图的简短概括

这里 intent 未必被后续节点消费,但让模型多输出一个「对自己有用」的字段,往往能起到链式思考(chain-of-thought)外显的效果,有助于提高 querys 质量——这是很多工作流里的小技巧。

联调小技巧:开发时可以把该节点输出直接连到 结束 节点,在结束节点里配置要暴露的变量,先验证「query 生成」是否稳定,再往下接搜索与写作。

下图可见:模型选用「豆包·2.0·pro」等;输入绑定「开始 → input」;输出解析为 JSON 字段(如 intentquerys 数组),供下一节点消费。

生成 query 节点:系统提示词、用户侧 {{input}}、JSON 输出 intent / querys

3. 循环 + 必应搜索:对多条 query 逐个检索

因为 querys 是数组,在「生成 query」后面 添加节点 → 循环;外层循环节点标题可写成「搜索并整理内容」一类,便于读图。

  • 循环类型:选「使用数组循环」;循环数组绑定上一大模型节点的 querys
  • 循环体内部:再点 添加节点 → 插件 → 必应搜索(或你工作区里可用的等价联网搜索插件)。每次迭代把当前元素映射为搜索的 querycount 控制条数;输出里的 data 等字段供循环汇总。
  • 输出映射:界面上常有经验顺序——先在循环体里把「必应搜索」节点接好、跑通,再回来配置循环节点对外的输出数组(否则没有可引用的中间结果)。

循环节点:数组绑定「生成 query → querys」,输出汇总检索结果

循环体内插件「必应搜索」:query 来自循环、count 控制返回条数

若不需要循环内的临时中间变量,可在 Coze 里按界面提示精简变量,避免图越来越乱。

4. 撰写草稿 → 润色(大模型)→ 语音合成(插件)→ 结束(默认)

在循环之后,把整理后的检索结果交给两个连续的大模型节点做「写稿 + 润色」,最后用插件出音。

撰写草稿添加节点 → 大模型。输入侧接入「搜索并整理内容」汇总后的材料;系统提示词约束「6~8 岁、经典尽量忠于原文」等;用户提示词用模板 参考资料:{{input}} 把变量喂进模型;输出 output(及可选 reasoning_content)供下一步使用。

撰写草稿节点:大模型,输入检索整理结果

润色:同样 添加节点 → 大模型。输入接 撰写草稿 → output;系统提示词切换为「温柔大姐姐给妹妹讲睡前故事」等人设;用户侧 故事材料:{{input}};输出仍为字符串 output

润色节点:大模型,承接草稿 output

语音合成添加节点 → 插件 → 搜索文本转语音(若控制台插件名称有细微差别,以实际列表为准)。将正文字段绑定 润色 → output;并按插件面板填写音色、cluster(如 volcano_tts)、app_id / app_token 等;输出里常见 link 指向生成音频 URL。

语音合成节点:插件「搜索文本转语音」,文本来自润色 output

结束:使用画布默认的「结束」节点即可;在配置里选 「返回变量」:例如 text 映射润色后的正文,audio 映射语音合成返回的 link(或平台等价字段),这样上层一次拿到「可读文本 + 可播音频」。

结束节点:返回变量 text(润色)与 audio(语音 link)

每一段的输入输出变量名要与前后节点对齐;逻辑顺序应与上文「核心工作流」示意图一致——你在 Coze 里是在「画图实现」同一张设计稿。


实践二:发布工作流,并挂到「对话智能体」

画布上的 工作流 解决「一条链怎么跑」;智能体(Bot) 解决「用户怎么对话触发这条链」。建议顺序:试运行并发布工作流 → 在资源库 创建智能体编排 → 技能 → 工作流 里添加已发布的工作流 → 预览与调试 验证触发与入参 → 发布智能体

试运行与发布工作流

bedtime_story(或你的工作流名)编辑页里先 试运行,确认「开始 → … → 结束」整条链无报错后,使用平台提供的 发布(或「上线」类)能力,把工作流变为 已发布 状态。只有发布后,智能体侧「添加工作流」列表里才容易稳定搜到它(具体按钮名称以 Coze 当前版本为准)。

在资源库创建智能体

进入 工作空间 → 资源库,右上角 「+ 创建」,选择 「创建智能体」(适用于对话式智能体)。

资源库中创建智能体入口

填写名片并确认

在弹窗里选 标准创建(或你需要的创建方式),填写 智能体名称功能介绍工作空间图标 等。示例中与睡前故事一致:儿童睡前故事 / 给 6-8 岁儿童讲睡前故事 等。点 确认 进入编排页。

创建智能体:名称、介绍、空间与图标

编排里挂载工作流技能

打开 编排,在 技能 区域找到 工作流 一行,点击右侧 「+」(提示为 添加工作流)。在列表中选中已发布的 bedtime_story,点 添加,把它挂到当前智能体上。这样用户发一句自然语言时,智能体才会按策略去 调用 你刚编排好的那条链。

编排页:技能 → 工作流 → 添加工作流

添加工作流弹窗:选择已发布的 bedtime_story

预览调试并发布智能体

右侧 预览与调试 里直接输入用户会说的主题(例如 「狼来了」)。若编排正确,应能看到 正在调用 bedtime_story 一类状态,并走完整条工作流(含你结束的 text / audio 等返回)。确认满意后,再在平台里 发布智能体,对外分享或接入渠道。

预览与调试:用户输入触发 bedtime_story 工作流

与「实践一」的关系:工作流 = 可复用的业务链;智能体 = 对话壳 + 默认模型 + 挂载的一条或多条工作流。先发布链,再把链挂到 Bot 上,用预览验证「用户一句话 → 工作流入参 input」是否对齐。


Coze 工作流的优缺点:适合当「哪一级」

优点

  • :复杂分支、流式输出、插件生态都能较快搭出可演示版本。
  • 省成本:适合创业者、团队做原型验证与需求对齐。
  • 可视化:非纯研发也能参与讨论「第几步该干什么」。

局限

  • 平台绑定:流程与数据多在 Coze 侧,对强隐私、强合规、专有部署的场景要慎重。
  • 节点内部偏黑盒:要做极致的数据结构优化、细粒度耗时分析时,不如代码透明。

因此常见节奏是:Coze 验证工作流是否合理 → 定型后用 Node.js(或其它后端) 把同一条 DAG 写成可维护的服务(与本仓库 server.js 编排多厂商 API 的思路一致)。理解「工作流原理」之后,换承载并不神秘。


小结:从草图到可对话的一条龙

  1. 先定义成功态与边界(对应上文「设计四步」前两条):用户拿什么结果、年龄与体裁等约束。
  2. 再画工作流(设计四步后两条 + mermaid):拆能力、定顺序、选承载。
  3. 实践一:Coze 画布:「大模型 → 循环 + 必应 → 大模型 ×2 → 搜索文本转语音」,开始/结束用默认节点。
  4. 实践二:上架:发布工作流 → 创建智能体 → 编排 → 技能 → 工作流 挂载 → 预览 → 发布智能体。
  5. 再决定要不要自研:原型通过后,用 Node.js 等复刻同一条 DAG(见本仓库 server.js 一类编排)。

智能体不是「多调几次模型」的代名词,而是**「多步能力 + 清晰编排」**的产物;设计稿 → 工作流画布 → 对话壳挂载 走完,就是从想法到可演示产品的完整一程。

昨天以前首页

图片大模型实践:可灵(Kling)文生图前后端实现

作者 颜酱
2026年4月14日 17:47

图片大模型实践:可灵(Kling)文生图前后端实现

本文讲图片模型里「可灵文生图」这一条链路:鉴权、代理、前端如何拼 URL、如何从异步任务结果里取出最终图片地址。语音或其它模型后续再单独开章节。

建议阅读顺序:先看下面「快速跑通」与「架构与数据流」,需要对照实现时再打开附录里的核心摘录或 GitHub 完整文件——不必在中间通读近千行粘贴代码。

可以先看下文本模型的文章,这篇是后续。

模型的使用,大差不差,去模型网站买额度,然后生成key,然后接口调用。


效果图

keling.gif

先去申请 可灵的 Key,可以的话充点小钱做实验。


一、快速跑通(三文件 + Git)

准备一个新目录,放入下面三个文件即可跑通可灵文生图(.env.local 勿提交到 Git)。

文件 作用
index-keling.html 前端单页:拼 URL、轮询、用 img 展示结果图
server.js 后端:读环境变量、签 JWT、转发 /kling/v1/...
.env.local(自建) 配置 ACCESS_KEY_IDACCESS_KEY_SECRET

克隆仓库:

git clone https://github.com/frontzhm/text-model.git
cd text-model

仓库主页: github.com/frontzhm/te…

.env.local 示例(与 server.js 同目录):

ACCESS_KEY_ID=你的AccessKey
ACCESS_KEY_SECRET=你的SecretKey
# 可选:KLING_API_ORIGIN=https://api-beijing.klingai.com

启动:

node server.js
# 另开终端,用静态服务打开页面(避免 file:// 下 ES Module 限制)
npx --yes serve .
# 浏览器访问 /index-keling.html,「代理」填 http://127.0.0.1:3000

二、为什么要有「后端」这一层?

可灵 API 与很多厂商一样,要求:

  1. 鉴权:用 AccessKey + SecretKey 按固定规则生成 JWT,放在 Authorization: Bearer <token> 里;
  2. HTTPS + 指定域名:国内新系统常用 https://api-beijing.klingai.com(与旧域名不同,用错域容易出现 401 / Auth failed);
  3. 浏览器限制:Secret 不能进前端;也不适合在页面里实现签名逻辑。

因此加一层 BFF:本仓库的 server.js 负责读 .env.local签发 JWT、把 /kling/v1/... 转发到可灵域名;浏览器只访问本地 http://127.0.0.1:3000


三、后端:server.js 里三件事

3.1 读环境变量

从项目根目录的 .env.local / .env 按行解析 KEY=value,例如:

  • ACCESS_KEY_ID / ACCESS_KEY_SECRET(或 KLING_* 别名)
  • 可选:KLING_API_ORIGIN(默认 https://api-beijing.klingai.com

3.2 生成 JWT(与官方 Python jwt.encode 一致)

  • Headeralg=HS256typ=JWT
  • Payloadiss = AccessKeyId,exp = now+1800s,nbf = now−5s
  • Signature:对 base64url(header).base64url(payload)HMAC-SHA256,再 Base64URL

使用 Node 内置 crypto.createHmac,无需 jsonwebtoken 包。

3.3 反向代理:路径「前缀剥离」+ 上游拼接

浏览器请求例如:http://127.0.0.1:3000/kling/v1/images/generations

  1. 剥前缀 /kling → 可灵 REST 路径 /v1/images/generations
  2. 拼上游KLING_API_ORIGIN + restPath + search
  3. 带上 Authorization: Bearer <刚签的 JWT> 转发 fetch,原样回写 status 与 body。

restPath 必须 /v1/ 开头且不含 ..,防止代理滥用。


四、前端:index-keling.html 在做什么?

技术栈:Vue 3(CDN ESM)。页面不存 AK/SK,只填代理根地址、Prompt、resolution / aspect_ratio 等。

4.1 创建任务(POST)

base = 代理根(去掉末尾 /),拼接提交地址:

endpoint = base + "/kling/v1/images/generations"

body 为 JSON payload(字段以官方文档为准),示例含 promptnegative_promptaspect_ratioresolution1k 一般比 2k 更省)。

响应里取 data.task_id

4.2 轮询(GET)——URL 拼接

resultUrl = endpoint + "/" + encodeURIComponent(task_id)

resultUrl 定时 GET,读 data.task_statussubmitted / processing 继续;failed 报错;否则解析 data.task_result.images[0].url

4.3 「图片拼接」指什么?(不是多图拼画布)

  • 接口 URLbase + 固定路径 + / + encodeURIComponent(id)
  • 展示:先把 imgUrl 设为 loading 图,成功后改为结果里的 HTTPS 图片 URL<img :src="imgUrl"> 由浏览器再去拉 CDN 图。

五、一次点击「Generate」的时序

sequenceDiagram
  participant B as 浏览器 index-keling.html
  participant S as server.js 代理
  participant K as api-beijing.klingai.com

  B->>S: POST /kling/v1/images/generations + JSON payload
  S->>S: 签发 JWT
  S->>K: POST /v1/images/generations + Bearer JWT
  K-->>S: 200 + task_id
  S-->>B: 透传 JSON

  loop 轮询
    B->>S: GET /kling/v1/images/generations/{task_id}
    S->>K: GET /v1/images/generations/{task_id} + Bearer JWT
    K-->>S: task_status + task_result...
    S-->>B: 透传 JSON
  end

  B->>B: imgUrl = task_result.images[0].url

六、省钱与排错

  • 分辨率payload.resolution1k 通常比 2k 更省(以官方计费为准)。
  • 401 / Auth failed:核对 北京域、AK/SK、重启 node server.js 后是否读到 .env.local
  • 422 / 字段错误:对照当前模型文档改 payload 字段名。

七、仓库文件对照

内容 文件
前端单页 index-keling.html
JWT + 代理 + DeepSeek 其它路由 server.js
环境说明 README.md

八、后续(语音等)

可按同一模板扩展:鉴权方式 → 是否需代理 → 前端拼 URL 还是拼流;语音若走流式或 WebSocket,「拼接」更多在 chunk 缓冲与解码,建议另开一篇写。


附录 A:核心代码摘录(与仓库一致)

完整可运行代码请以仓库为准;下面仅保留与可灵最相关的片段。

A.1 server.js:JWT + 代理(节选)

const KLING_API_ORIGIN = (
  process.env.KLING_API_ORIGIN || 'https://api-beijing.klingai.com'
).trim()
const KLING_PATH_PREFIX = '/kling'

function signKlingJwt(accessKeyId, accessKeySecret) {
  const now = Math.floor(Date.now() / 1000)
  const header = { alg: 'HS256', typ: 'JWT' }
  const payload = { iss: accessKeyId, exp: now + 1800, nbf: now - 5 }
  const h = toBase64Url(JSON.stringify(header))
  const p = toBase64Url(JSON.stringify(payload))
  const signingInput = `${h}.${p}`
  const sig = crypto
    .createHmac('sha256', accessKeySecret)
    .update(signingInput)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
  return `${signingInput}.${sig}`
}

// createServer 内:pathname 以 /kling 开头则 await proxyKlingRequest(...)
// proxyKlingRequest:restPath = pathname 去掉 /kling;拼 targetUrl;Bearer 调用 fetch(upstream)

toBase64UrlreadRequestBodyCORSloadDotEnv 及 DeepSeek 路由见仓库文件。)

A.2 index-keling.html:提交与轮询 URL(节选)

const endpoint = `${base}/kling/v1/images/generations`
const payload = {
  prompt: prompt.value.trim(),
  negative_prompt: negativeWords,
  aspect_ratio: aspectRatio.value,
  resolution: resolution.value
}
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload) })
const id = (await res.json()).data?.task_id
const resultUrl = `${endpoint}/${encodeURIComponent(id)}`
// 循环 fetch(resultUrl) 直到非 processing/submitted …

(Vue template<style>、localStorage 与错误处理见仓库完整 HTML。)


附录 B:完整源码一键打开(Raw)

便于整文件复制:


从 DeepSeek 文本对话到流式输出

作者 颜酱
2026年4月14日 11:27

从 DeepSeek 文本对话到流式输出

本文把「非流式调用 → 浏览器里解析流式 → 用 Node 做 BFF → 前端改用 EventSource」串成一条主线

你将学到什么

  • 在浏览器里用 fetch 调用 DeepSeek 的 Chat Completions(与 OpenAI 兼容)。
  • 为什么要开 stream,以及流式响应在控制台里长什么样。
  • ReadableStream + TextDecoder + 行缓冲 解析 data: 开头的 SSE 分片。
  • 为什么 EventSource 很难直接对接「POST + Authorization」的大模型接口,以及如何用 零依赖 server.js 做中转。
  • 前端如何用 EventSource 消费自家 BFF 下发的 SSE,并顺带了解 SSE 的基本格式。

最后效果

deep_text.gif

懒得本地建立代码,也可以直接clone代码index-direct.htmlindex-stream.html能直接拖到浏览器,看效果。index.html拖入浏览器之前,需要首先node server.js,然后也能看到效果。哦,前提去申请一个deepseek的key。

准备工作

  1. 打开 DeepSeek 开放平台,按需充值并创建 API Key,妥善保存(不要写进公开仓库)。
  2. 下文示例里,直连 DeepSeek 的页面会把 Key 放在浏览器侧(仅适合本地学习);走代理后,Key 只放在服务端 .env.local

一、非流式:一次性拿到完整回复

复杂问题之前,先用「一问一答、整包返回」把链路跑通:向 https://api.deepseek.com/chat/completionsPOSTstream 关闭(或省略),再从 choices[0].message.content 取文本。

下面是一段最小 HTML(body 里放展示区域 + type="module" 脚本即可),新建文件,然后丢到浏览器就行!

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <body>
    <div id="reply"></div>
  
    <script type="module">
      const API_KEY = "sk-你自己的,没有的话去申请 https://platform.deepseek.com/usage";
      // DeepSeek 的「对话补全」接口地址(与 OpenAI Chat Completions 格式兼容)
      const endpoint = "https://api.deepseek.com/chat/completions";
  
      // HTTP 请求头:声明 JSON 正文,并用 Bearer Token 携带 API Key
      const headers = {
        "Content-Type": "application/json",
        Authorization: `Bearer ${API_KEY}`,
      };
  
      // 请求体:指定模型、对话消息列表;isStream: false 表示要一次性返回完整结果,而不是流式 SSE
      const payload = {
        // 模型类型
        model: "deepseek-chat",
        messages: [
          // role 字段是一个枚举字段,可选的值分别是 system、user 和 assistant,依次表示该条消息是系统消息(也就是我们一般俗称的提示词)、用户消息和 AI 应答消息
          { role: "system", content: "You are a helpful assistant." }, // 系统提示,约束助手行为
          { role: "user", content: "你好 Deepseek" }, // 用户本轮输入
        ],
        isStream: false,
      };
  
      // 向 DeepSeek 发起 POST,把 payload 序列化成 JSON 字符串作为 body
      const response = await fetch(endpoint, {
        method: "POST",
        headers: headers,
        body: JSON.stringify(payload),
      });
  
      // 把响应体解析为 JSON;接口成功时 choices[0].message.content 即助手回复正文
      const data = await response.json();
  
      // 把大模型返回的文本显示就行了
      document.getElementById("reply").textContent =
        data.choices[0].message.content;
    </script>
  </body>
</body>
</html>

二、为什么要流式:体感更好,协议长什么样

简单问题整包返回没问题;问题一长,用户会长时间盯着空白。把请求里的 stream 设为 true,模型就会边生成边吐字,前端边读边展示。

流式时,控制台里常见一行以 data: 开头,后面跟一段 JSON;结束标记一般是 data: [DONE](注意是 [DONE],大小写与官方一致)。下面是一条真实形态示例(单行 JSON,便于你对照日志):

data: {"id":"07b44fd1-5339-4ea5-a3e5-e62464fabe3d","object":"chat.completion.chunk","created":1776131664,"model":"deepseek-chat","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache_new_kvcache_20260410","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":972,"total_tokens":982,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":10}}

三、浏览器里用 fetch + ReadableStream 解析 SSE(Vue CDN 单页)

这一版页面做了几件事:

  • Vue 3(CDN ESM) 做一个最小界面:Key、问题、是否流式、提交按钮。
  • 流式时:response.body.getReader() + TextDecoder,按行切分;只处理以 data: 开头的行;能 JSON.parse 就读 choices[0].delta.content 做增量;解析失败就把半行塞回缓冲区,等下一段数据补齐。
  • 非流式:response.json() 一次取全量。

下面给出完整单页 HTML(可直接本地打开试用;Key 仅保存在本机 localStorage不要把带 Key 的页面部署到公网):

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          const apiKey = ref(
            typeof localStorage !== "undefined"
              ? localStorage.getItem("deepseek_api_key") || ""
              : "",
          );
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          function saveKey() {
            try {
              localStorage.setItem("deepseek_api_key", apiKey.value.trim());
            } catch (_) {}
          }

          async function update() {
            const key = apiKey.value.trim();
            if (!key) {
              error.value = "请填写 API Key(仅保存在本机 localStorage)";
              return;
            }
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            error.value = "";
            loading.value = true;
            content.value = isStream.value ? "" : "思考中…";
            saveKey();

            const endpoint = "https://api.deepseek.com/chat/completions";
            const headers = {
              "Content-Type": "application/json",
              Authorization: "Bearer " + key,
            };

            try {
              const response = await fetch(endpoint, {
                method: "POST",
                headers,
                body: JSON.stringify({
                  model: "deepseek-chat",
                  messages: [{ role: "user", content: question.value.trim() }],
                  stream: isStream.value,
                }),
              });

              if (!response.ok) {
                const errText = await response.text();
                throw new Error(response.status + " " + errText.slice(0, 200));
              }

              if (isStream.value) {
                content.value = "";
                const reader = response.body?.getReader();
                if (!reader) {
                  throw new Error("响应不支持 ReadableisStream");
                }

                const decoder = new TextDecoder();
                let sseBuffer = "";

                while (true) {
                  const { value, done } = await reader.read();
                  if (done) break;

                  sseBuffer += decoder.decode(value, { isStream: true });
                  const parts = sseBuffer.split("\n");
                  sseBuffer = parts.pop() ?? "";

                  for (const rawLine of parts) {
                    const line = rawLine.trim();
                    if (!line || line.startsWith(":")) continue;
                    if (!line.startsWith("data:")) continue;
                    console.log(line);
                    const payload = line.slice(5).trim();
                    if (payload === "[DONE]") {
                      loading.value = false;
                      return;
                    }

                    try {
                      const data = JSON.parse(payload);
                      const delta = data?.choices?.[0]?.delta?.content;
                      if (delta) content.value += delta;
                    } catch {
                      sseBuffer = rawLine + "\n" + sseBuffer;
                    }
                  }
                }

                if (sseBuffer.trim()) {
                  const line = sseBuffer.trim();
                  if (line.startsWith("data:")) {
                    const payload = line.slice(5).trim();
                    if (payload && payload !== "[DONE]") {
                      try {
                        const data = JSON.parse(payload);
                        const delta = data?.choices?.[0]?.delta?.content;
                        if (delta) content.value += delta;
                      } catch (_) {}
                    }
                  }
                }
              } else {
                const data = await response.json();
                const text = data?.choices?.[0]?.message?.content;
                content.value = text ?? JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              if (!isStream.value) content.value = "";
            } finally {
              loading.value = false;
            }
          }

          return {
            apiKey,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(流式 / 非流式)</h1>
            <p class="hint">
              单文件演示:API Key 存于浏览器 localStorage。请勿把含 Key 的页面上传到公网。
            </p>
            <div class="row">
              <label for="k">Key</label>
              <input id="k" type="password" v-model="apiKey" placeholder="sk-…" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

到这里可以记住一句话:流式开关在请求体里的 stream 字段;而浏览器侧「读流」的套路,基本就是 ReadableStream 读片 + 文本解码 + 行缓冲 + 解析 data: JSON


四、SSE 与「为什么不能在前端直接用 EventSource 调大模型」

DeepSeek 以及大量兼容 OpenAI 的平台,流式输出本质上是标准的 Server-Sent Events(SSE):文本协议、单向(服务端 → 浏览器)、比 WebSocket 轻。

EventSource 按规范只支持 GET,且不方便携带我们常用的 Authorization: Bearer ...;而大模型对话接口又通常是 POST + JSON body。所以:不是浏览器不能玩 SSE,而是不能「直接用 EventSource」去怼官方大模型域名

常见工程化解法是做一层 BFF(Backend For Frontend):由 Node 持有密钥,替浏览器去 POST 上游,再把上游 SSE 裁剪/转写成浏览器更好消费的 SSE(或 JSON 行)。


五、零 npm 的 Node 代理:server.js

这里用 Node 22+ 内置 http / fs / path / fetch,不引入 expressdotenv 等依赖,文件即服务。

  • 从项目根目录读取 .env.local.env(简单解析 KEY=value)。
  • GET /stream?question=...:上游 stream: true,把增量以 SSE 写回(示例里对纯文本 delta 做了 JSON.stringify,避免正文换行弄坏 SSE)。
  • GET /complete?question=...:上游 stream: false,返回 { "content": "..." },给「非流式」前端一条同源捷径。

在同级目录创建 .env.local,写入一行(示例):

VITE_DEEPSEEK_API_KEY=sk-你自己的

然后执行:

node server.js

完整服务端代码如下(文件名 server.js):

"use strict";

/**
 * 零依赖代理:Node 22+(内置 fetch)
 * 启动:node server.js
 * 环境变量:在项目根目录放置 .env.local 或 .env,写入
 *   VITE_DEEPSEEK_API_KEY=sk-...
 * 可选:PORT=3000、DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
 *
 * 调用示例:
 *   curl -N "http://localhost:3000/stream?question=你好"
 */

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const ROOT = __dirname;

function loadDotEnv() {
  for (const name of [".env.local", ".env"]) {
    const file = path.join(ROOT, name);
    if (!fs.existsSync(file)) continue;
    const text = fs.readFileSync(file, "utf8");
    for (const line of text.split(/\n/)) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith("#")) continue;
      const eq = trimmed.indexOf("=");
      if (eq === -1) continue;
      const key = trimmed.slice(0, eq).trim();
      let val = trimmed.slice(eq + 1).trim();
      if (
        (val.startsWith('"') && val.endsWith('"')) ||
        (val.startsWith("'") && val.endsWith("'"))
      ) {
        val = val.slice(1, -1);
      }
      if (process.env[key] === undefined) process.env[key] = val;
    }
  }
}

loadDotEnv();

const PORT = Number(process.env.PORT) || 3000;
const API_KEY =
  process.env.VITE_DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || "";
const UPSTREAM =
  process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";

if (!API_KEY) {
  console.error(
    "缺少 API Key:请在 .env.local 或 .env 中设置 VITE_DEEPSEEK_API_KEY(或 DEEPSEEK_API_KEY)",
  );
  process.exit(1);
}

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

/**
 * 将上游 OpenAI 兼容 SSE 行解析为 delta 文本,并写给客户端
 * @param {import('node:http').ServerResponse} res
 * @param {ReadableStreamDefaultReader<Uint8Array>} reader
 */
async function pipeUpstreamSseToClient(res, reader) {
  const decoder = new TextDecoder();
  let carry = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    carry += decoder.decode(value, { stream: true });

    let nl;
    while ((nl = carry.indexOf("\n")) !== -1) {
      const rawLine = carry.slice(0, nl);
      carry = carry.slice(nl + 1);
      const line = rawLine.trim();
      if (!line || line.startsWith(":")) continue;
      if (!line.startsWith("data:")) continue;

      const payload = line.slice(5).trim();
      if (payload === "[DONE]") {
        res.write("event: end\n");
        res.write("data: [DONE]\n\n");
        return;
      }

      try {
        const data = JSON.parse(payload);
        const delta = data?.choices?.[0]?.delta?.content;
        if (delta) {
          // 用 JSON 包裹一段文本,避免 delta 内含换行破坏 SSE
          res.write(`data: ${JSON.stringify(delta)}\n\n`);
        }
      } catch {
        carry = `${rawLine}\n${carry}`;
        break;
      }
    }
  }

  res.write("event: end\n");
  res.write("data: [DONE]\n\n");
}

const server = http.createServer(async (req, res) => {
  const host = req.headers.host || `127.0.0.1:${PORT}`;
  let url;
  try {
    url = new URL(req.url || "/", `http://${host}`);
  } catch {
    res.writeHead(400, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end("bad url");
    return;
  }

  if (req.method === "OPTIONS") {
    res.writeHead(204, CORS);
    res.end();
    return;
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end(
      `DeepSeek 代理已就绪。\n\n流式:GET /stream?question=你的问题\n非流式:GET /complete?question=你的问题\n示例:http://localhost:${PORT}/stream?question=你好\n`,
    );
    return;
  }

  if (req.method === "GET" && url.pathname === "/complete") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ error: "缺少参数:question" }));
      return;
    }

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: false,
        }),
      });

      const text = await upstream.text();
      if (!upstream.ok) {
        res.writeHead(upstream.status, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(
          JSON.stringify({ error: "upstream", body: text.slice(0, 800) }),
        );
        return;
      }

      let data;
      try {
        data = JSON.parse(text);
      } catch {
        res.writeHead(502, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(JSON.stringify({ error: "上游返回非 JSON" }));
        return;
      }

      const content = data?.choices?.[0]?.message?.content ?? "";
      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ content }));
    } catch (e) {
      res.writeHead(500, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(
        JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
      );
    }
    return;
  }

  if (req.method === "GET" && url.pathname === "/stream") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "text/plain; charset=utf-8",
        ...CORS,
      });
      res.end("缺少参数:question");
      return;
    }

    res.writeHead(200, {
      ...CORS,
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    });

    const ac = new AbortController();
    const onClose = () => ac.abort();
    res.on("close", onClose);

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: true,
        }),
        signal: ac.signal,
      });

      if (!upstream.ok || !upstream.body) {
        const t = await upstream.text().catch(() => "");
        res.write(
          `data: ${JSON.stringify({
            error: `upstream ${upstream.status}`,
            body: t.slice(0, 800),
          })}\n\n`,
        );
        return;
      }

      await pipeUpstreamSseToClient(res, upstream.body.getReader());
    } catch (e) {
      if (e?.name === "AbortError") {
        return;
      }
      console.error(e);
      res.write(
        `data: ${JSON.stringify({ error: e instanceof Error ? e.message : String(e) })}\n\n`,
      );
    } finally {
      res.off("close", onClose);
      if (!res.writableEnded) res.end();
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", ...CORS });
  res.end("not found");
});

server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(
    `Stream: curl -N "http://localhost:${PORT}/stream?question=你好"`,
  );
  console.log(
    `Complete: curl "http://localhost:${PORT}/complete?question=你好"`,
  );
});

六、前端改用 EventSource:更轻的一层消费

当密钥已经只在服务端时,浏览器不再需要输入 Key。流式场景下,用 EventSource 连接自家代理,例如:

http://127.0.0.1:3000/stream?question=...question 请做 URL 编码)

下面是与当前仓库一致的 index.html 版本:流式走 EventSource,非流式走 GET /complete;并把代理根地址记在 localStorage 里,方便反复调试。

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
        onUnmounted,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          /** 本地 node server.js 地址(与 server 监听端口一致) */
          const proxyBase = ref("http://127.0.0.1:3000");
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          /** @type {EventSource | null} */
          let eventSource = null;

          function closeEventSource() {
            if (eventSource) {
              eventSource.close();
              eventSource = null;
            }
          }

          onUnmounted(() => {
            closeEventSource();
          });

          function saveProxyBase() {
            try {
              localStorage.setItem(
                "deepseek_proxy_base",
                proxyBase.value.trim(),
              );
            } catch (_) {}
          }

          async function update() {
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            const base = proxyBase.value.trim().replace(/\/$/, "");
            if (!base) {
              error.value = "请填写代理地址";
              return;
            }

            error.value = "";
            closeEventSource();
            saveProxyBase();

            const q = encodeURIComponent(question.value.trim());

            if (isStream.value) {
              loading.value = true;
              content.value = "";

              const url = `${base}/stream?question=${q}`;
              const es = new EventSource(url);
              eventSource = es;

              es.addEventListener("message", (e) => {
                if (e.data === "[DONE]") return;
                try {
                  const parsed = JSON.parse(e.data);
                  if (
                    parsed &&
                    typeof parsed === "object" &&
                    parsed !== null &&
                    "error" in parsed
                  ) {
                    error.value =
                      typeof parsed.error === "string"
                        ? parsed.error
                        : JSON.stringify(parsed.error);
                    closeEventSource();
                    loading.value = false;
                    return;
                  }
                  if (typeof parsed === "string") {
                    content.value += parsed;
                  }
                } catch {
                  error.value = "SSE 解析失败:" + e.data;
                  closeEventSource();
                  loading.value = false;
                }
              });

              es.addEventListener("end", () => {
                closeEventSource();
                loading.value = false;
              });

              es.onerror = () => {
                if (!error.value) {
                  error.value =
                    "EventSource 连接失败(请确认已运行 node server.js,且代理地址、端口正确)";
                }
                closeEventSource();
                loading.value = false;
              };

              return;
            }

            loading.value = true;
            content.value = "思考中…";
            try {
              const res = await fetch(`${base}/complete?question=${q}`);
              const data = await res.json().catch(() => ({}));
              if (!res.ok) {
                throw new Error(
                  typeof data.error === "string"
                    ? data.error
                    : res.status + " " + JSON.stringify(data).slice(0, 200),
                );
              }
              if (data && typeof data.content === "string") {
                content.value = data.content;
              } else {
                content.value = JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              content.value = "";
            } finally {
              loading.value = false;
            }
          }

          if (typeof localStorage !== "undefined") {
            const saved = localStorage.getItem("deepseek_proxy_base");
            if (saved) proxyBase.value = saved;
          }

          return {
            proxyBase,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(经本地 server.js)</h1>
            <p class="hint">
              先在本项目目录运行 <code>node server.js</code>(Key 写在服务端 .env.local)。流式走
              <code>GET /stream</code>(EventSource),非流式走 <code>GET /complete</code>。
            </p>
            <div class="row">
              <label for="proxy">代理</label>
              <input id="proxy" type="text" v-model="proxyBase" placeholder="http://127.0.0.1:3000" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .hint code {
        font-size: 0.85em;
        padding: 0.12em 0.4em;
        border-radius: 4px;
        background: #21262d;
        color: #79c0ff;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

看效果:

  • node server.js启动服务
  • index.html直接在浏览器打开就行

七、和「手写 ReadableStream」相比:EventSource 在写什么

有了 BFF 之后,浏览器侧可以收敛成「连接 + 监听消息 + 结束关闭」的写法。下面是一段示意(注意:真实页面里 e.data 往往是 JSON 字符串化的片段,需要 JSON.parse 后再拼接,见上一节完整 index.html;这里保留原文写法不动):

const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
  content.value += e.data;
});
eventSource.addEventListener('end', () => {
  eventSource.close();
});

除了代码更短之外,EventSource 还自带自动重连语义(适合长连接场景;生产环境仍要结合幂等、去重与产品体验谨慎使用)。标准里也提到 Last-Event-ID 等能力,用于断线续传时减少重复流量(是否启用取决于你的 BFF 设计)。


八、附录:SSE 是什么、数据长什么样

SSE(Server-Sent Events):服务端主动向浏览器推送事件流,单向、基于 HTTP,通常比 WebSocket 更轻。

前端最小用法示例(与具体业务路径无关,仅演示 API):

// 建立 SSE 连接
const evtSource = new EventSource('/api/sse');

// 监听服务器发来的消息
evtSource.onmessage = (e) => {
  console.log('收到消息:', e.data);
};

// 监听错误
evtSource.onerror = (err) => {
  console.error('SSE 出错', err);
};

// 关闭连接
evtSource.onmessage = (e) => {
  if (e.data === 'done') {
    evtSource.close(); // 关闭 SSE 连接
    return;
  }
  console.log(e.data);
};

服务端写入时,需要满足 SSE 的基本形态:Content-Type: text/event-stream,消息以 data: 开头,并以 空行(\n\n 结束一条事件。下面用注释标了几种常见形态(注意:注释行只是说明,真实协议里注释行以 : 开头,这里保留原文示例不动):


data: 你好任意巴拉巴拉\n\n

# json串
data: {"name":"小明","age":20}\n\n

# 自定义结束 前端获取就行
data: done\n\n

# 发空行表示心跳
data: \n\n

Node 里设置响应头并周期性 write 的伪代码如下(仅帮助理解,不是可直接运行的完整服务):

// 伪代码
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// 每隔 1 秒发一条
setInterval(() => {
  res.write(`data: ${new Date()}\n\n`);
}, 1000);

结束标记(例如 done)可以自定义,但团队内最好统一约定;本文 BFF 示例则使用 event: end + data: [DONE] 的组合来通知前端收尾。


小结

  • 直连fetch + stream + ReadableStream 解析 data: 行,灵活但代码多,且 Key 在浏览器。
  • BFF:Node 持有 Key,浏览器用 EventSource/fetch 访问同源或可控跨域接口,职责更清晰。
  • 安全:Key 进 .env.local.gitignore 忽略本地环境文件;页面不要上传公网。

祝调试顺利。

❌
❌