第一次流式调用(SDK只是包装了它们)
php
const API_KEY = process.env.DEEPSEEK_API_KEY; // 在 platform.deepseek.com 申请并充值
const res = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "deepseek-v4-flash", // 当前模型名,用前查官方文档
messages: [
{ role: "system", content: "你是电力数据分析助手,回答简洁。" },
{ role: "user", content: "用一句话解释:为什么 RAG 能让模型回答私有文档问题?" },
],
stream: true,
}),
});
// 流式响应是 SSE 格式:每块 "data: {...}\n\n",末尾 "data: [DONE]"
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop(); // 保留可能未完整的最后一行
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") continue;
const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
if (delta) process.stdout.write(delta); // 不换行,模拟打字机
}
}
process.stdout.write("\n");
fetch(url, options) 发出一个 HTTP POST 请求 → 服务端返回一个**流式(SSE)**响应 → 后面第 45--77 行逐块读取,把 content 增量拼出来打印,实现"打字机"效果。
css
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Ai_object.apiKey}`
}
Content-Type: application/json
- 声明 body 里的内容是 JSON 格式,让服务端按 JSON 去解析。
- 关键点:如果你不写它,
fetch在浏览器/Node 里对字符串 body 会自动设成text/plain;charset=UTF-8,服务端解析不了,通常直接415报错。所以发 JSON 必须手动指定。 - deepseek中去掉
Content-Type: application/json返回status:415,statusText: 'Unsupported Media Type','content-type': 'text/plain; charset=utf-8', - 千问中,status: 200,statusText: 'OK',
'content-type': 'text/event-stream; charset=utf-8' - 只有
Content-Type设成application/json才能配合body: JSON.stringify(...)使用。
Authorization: Bearer ${Ai_object.apiKey}
- 认证头,格式是
Bearer <令牌>(Bearer = "持有者令牌")。服务端靠这个识别你是谁、有没有额度,没有它就返回401 Unauthorized。 - 模板字符串把
Ai_object.apiKey(来自环境变量QW_API_KEY)拼进去,避免把密钥硬编码在代码里。
没写但常见的请求头
| 头 | 作用 | 是否建议加 |
|---|---|---|
Accept: text/event-stream |
声明"我接受流式返回"。开 stream: true 后多数网关按这个头决定响应格式 |
建议加,更稳 |
Accept: application/json |
声明接受普通 JSON(不开流时用) | 可选 |
X-DashScope-SSE: disable |
DashScope 原生接口专用,兼容模式不需要 | 不用加 |
deepseek中加上Accept一定带上charset=utf-8, "Accept": "text/event-stream;charset=utf-8" 不加会有乱码
body ------ 真正发给模型的内容
JSON.stringify({...}) 把 JS 对象序列化成 JSON 字符串再放进 body(HTTP 只能传文本/二进制,不能直接传对象)。
body 里逐字段看:
| 字段 | 作用 |
|---|---|
model |
用哪个模型(qwen3.8-max,deepseek-v4-pro)。服务端按模型计费、决定能力上限 |
messages |
对话历史,核心。按顺序组成上下文 |
role: "system" |
系统指令,设定助手人设/行为规则("你是电力数据分析助手,回答简洁")。优先级最高,会影响所有回答 |
role: "user" |
用户提问 |
stream: true |
开启流式。让模型边生成边返回,而不是等全部生成完一次性返回 |
关于 messages 多说两句 :这个数组顺序即上下文顺序。如果要多轮对话,得把历史问答按 user/assistant/user/assistant... 顺序追加进来,模型"记住"的上文全靠它。这也是 RAG 的入口------把检索到的私有文档内容拼成一段上下文塞进 system 或 user 消息里,模型就能"回答私有文档问题"了。
为什么这里要 stream: true :默认是 false,即服务端攒完整段回答一次性给你,通常几秒到几十秒才响应,体验差。开了流式后响应变成 text/event-stream,一行行推送 data: {...} 增量(最后以 data: [DONE] 结束)------所以第 20-36 行要用 getReader() 一字节一字节读、按 \n 切行、跳过非 data: 行、解析 delta.content 再打印,整个过程就是 SSE 协议的手工实现。
请求体里"没写但都有默认值"的模型参数
这些是 OpenAI 兼容接口的可选参数,不传就走服务端默认。对做 AI 应用很关键,建议按需补充:
| 参数 | 默认值 | 作用 / 什么时候用 |
|---|---|---|
temperature |
随模型约 1.0(控制随机性) | 越高越发散、越低越保守确定。需要事实型回答时调低如 0.2;创作时调高 |
top_p |
1 |
核采样,与 temperature 二选一调节,一般不同时动 |
max_tokens(新接口max_completion_tokens) |
模型上限 | 限制单次回答最大长度,省钱、防超长 |
stop |
null |
遇到这些字符串就停止生成,最多 4 个 |
presence_penalty |
0 |
越高越鼓励谈论新话题 |
frequency_penalty |
0 |
越高越避免重复用词 |
n |
1 |
一次生成几条候选回答 |
stream_options |
默认不返回统计 | { include_usage: true } 时流末尾会带 token 用量,方便统计成本 |
tools + tool_choice |
不使用 | 函数调用/工具调用,让模型决定调你定义的函数(做 RAG/查数据库/Agent 的基石) |
response_format |
text |
设 { type: "json_object" } 可强制输出合法 JSON |
seed |
随机 | 固定随机种子,让多次结果更可复现(不保证绝对一致) |
user |
无 | 传终端用户标识,用于服务端审计/限流 |
① max_tokens 防止模型一次性吐太长;② 想省钱又想要稳定问答就调低 temperature。
服务端到底发牛了什么?
打印const reader = res.body.getReader();得到
yaml
ReadableStreamDefaultReader {
stream: ReadableStream { locked: true, state: 'readable', supportsBYOB: true },
readRequests: 0,
close: Promise { <pending> }
}
ini
const readerObj = await reader.read();
//readerObj内容如下:
readerObj {
value: Uint8Array(461) [
100, 97, 116, 97, 58, 32, 123, 34, 105, 100, 34, 58,
34, 99, 53, 102, 51, 48, 55, 49, 54, 45, 56, 97,
97, 97, 45, 52, 101, 52, 55, 45, 57, 100, 48, 97,
45, 57, 52, 51, 98, 49, 100, 55, 53, 102, 52, 102,
98, 34, 44, 34, 111, 98, 106, 101, 99, 116, 34, 58,
34, 99, 104, 97, 116, 46, 99, 111, 109, 112, 108, 101,
116, 105, 111, 110, 46, 99, 104, 117, 110, 107, 34, 44,
34, 99, 114, 101, 97, 116, 101, 100, 34, 58, 49, 55,
56, 56, 55, 54,
... 361 more items
],
done: false
}
const decoder = new TextDecoder();
decoder.decode(value)
我们循环中打印decoder.decode(value) 格式如下:
kotlin
// ------ 阶段一:思考中(先来)------
data: {"choices":[{"delta":{"reasoning_content":"用户问的是 RAG 为什么能让模型回答私有文档问题。让我先理清:私有文档不在训练数据里,模型本身不知道......","content":""}}]}
// ------ 阶段二:正式回答(后到)------
data: {"choices":[{"delta":{"reasoning_content":"","content":"因为 RAG 把私有文档"}}]}
data: {"choices":[{"delta":{"reasoning_content":"","content":"切成片段检索后塞进上下文,让模型"}}]}
data: [DONE]
结果只打印了content内容,如果对于简单内容的回答,或者非生产环境,测试阶段,不需要推理思考内容reasoning_content(节约token)可以关闭。 标准 OpenAI 接口没有这个字段(官方把推理藏起来了)。这是 DeepSeek 先引入、qwen3/Kimi 等思考模型沿用的一种"兼容扩展"。
bash
deepseek
{
model:'deepseek-xxx',
thinking: {
type: "disabled" //[enabled,disabled]
}
}
qwen
{
model:'qwen-xxx',
enable_thinking: false // [true,false]
}
怎么展示content呢 stream: true 时,fetch 的响应不是一次性 JSON ,而是 text/event-stream 格式的文本流,长相如上:
规则就三条:
- 每个事件 = 一行
data:开头的内容 + 一个空行隔开 - 文字增量藏在
choices[0].delta.content里 - 全部结束时发
data: [DONE]而 TCP/HTTP 传输是分包 的,网络包可能在任意字节处切断------可能一行被切成两半分两次发来。这段代码的全部技巧,都是为了应对这件事。
1. 准备三样东西
ini
const reader = res.body.getReader(); // 拿到响应体的"读取器"
const decoder = new TextDecoder(); // 字节 -> 字符串 的解码器
let buf = "" // 手工缓冲区:攒着没拼完整的半行
res.body是一个 ReadableStream (可读流)。它不是整个文件,而是管道:服务端发多少,你就能读多少,边到边处理。getReader()返回 reader,像一根吸管,通过reader.read()一口一口吸。TextDecoder把Uint8Array(二进制字节)翻译成 JS 字符串,因为 HTTP 传的是字节,data:文本是字节编码出来的。buf是这次解析的灵魂:存不完整的一行,等下一个网络包来了再拼上。
2. 读循环:吸管一口一口吸
bash
while (true) {
const { done, value } = await reader.read();
if (done) { break; }
...
}
-
reader.read()返回一个 Promise,await等到下一个网络包到达才返回。 -
解构出两个东西:
done(布尔):true= 流已经全部读完(服务端关闭了连接),该break退出循环;value(Uint8Array):这一口吸到的原始字节。可能是几行,也可能只有半个字符。
注意:
read()是"来一块处理一块",不等全部数据到齐------这正是流式的意义。如果服务端一直不发新包,这里就会一直挂起等待。3. 缓冲区算法:把字节切成"完整行 + 残缺尾巴"
这是全段最核心、也最容易被忽略的一行:
php
buf += decoder.decode(value, {stream: true}) // ① 字节转文本,拼到 buffer
const lines = buf.split("\n") // ② 按换行符切开
buf = lines.pop() // ③ 最后一段可能残缺,存回去等下次
逐步解释:
- ① 解码拼接 :每个网络包
value都是一堆字节,decoder.decode()变成字符串,追加到buf。此时buf里可能混着"完整行 + 半个行尾巴"。 - ② 切行 :
"aa\nbb\ncc".split("\n")→["aa", "bb", "cc"]。注意:只要行尾带\n,split就把它当成一段完整行。 - ③ 尾巴回炉 :
lines.pop()取出数组最后一项 。为什么最后一项要特殊处理?因为网络包常在某行中间 被切断,最后一段没有\n结尾,是残缺的 。把它塞回buf,等下一个包到达拼起来再切------保证永远不会因为"半个data:行"而解析出错。
举例,假设两个网络包:
css
包1 = `data: {"choices":[{"delta":{"content":"风`
包2 = `力数据分析助手"}}]}\n\n`
- 处理包1:
buf = 'data: {"choices":...{"content":"风'(没换行)→split只有一项 →pop把它全部存回buf。循环里一个字符都不处理。 - 处理包2:
buf = 包1 + 包2 = 'data: {...{"content":"风力数据分析助手"}}]}\n\n'→ 现在有\n了 → 切成完整行,才真正开始解析。
没有
buf缓存的话,包1 那半行就会被当成"不以 data: 开头"而静默丢弃,回答会缺字。
4. 逐行提取增量文字
arduino
for (const line of lines) {
if (!line.startsWith("data:")) continue // 只认 data: 开头的行
const payload = line.slice(6).trim() // 去掉 "data: " 前缀
if (payload === "[DONE]") continue // 结束哨兵,跳过
const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
if (delta) {
process.stdout.write(delta) // 直接写到终端,不带换行
}
}
逐条看:
-
startsWith("data:"):SSE 流里除了data:行还可能有注释(:开头)、空行等,全部过滤掉,只处理有效内容行。 -
slice(6) + trim:去掉"data: "这 6 个字符(data:5 个 + 空格 1 个),再trim()掉两端空白。⚠️ 这是个隐式假设 :默认服务端格式是data:(冒号后带一个空格 )。trim()让slice(5)更保险------后面"坑"里细说。 -
payload === "[DONE]":看到结束哨兵就continue跳过。这里值得注意:它只是跳过这一行 ,真正的退出靠的是外层reader.read()返回done: true。 -
JSON.parse(payload):把每行data:后面的 JSON 字符串还原成对象。这就是一个 SSE 事件里的增量包。 -
choices?.[0]?.delta?.content:可选链?.一路防护,任何一层不存在都返回undefined而不是报错。语义:取第一个候选(choices[0])的增量(delta)里的文字(content)。- 中途包:
{ delta: { content: "风" } }→ 有 content,取到文字 - 结束包:
{ delta: {}, finish_reason: "stop" }→content是undefined,跳过 - 用量包:
{ choices: [], usage: {...} }→choices[0]是undefined,?.拦下,不崩
- 中途包:
-
process.stdout.write(delta):直接写终端且不带换行 ,所以每个增量紧挨着上一个------这就是"打字机逐字输出"效果的来源。若用console.log会每个字换一行,效果就毁了。
5. 收尾
lua
process.stdout.write("\n")
循环因 done: true 退出后补一个换行,让光标回到下一行(否则模型说完话你的终端提示符会紧贴在回答后面)。
一张图走通全程
bash
服务端 ──TCP──▶ 网络包1: 'data: {"choices":[{"delta":{"content":"风'
buf = 这半行(无\n) → split 只有一项 → 存回 buf → 不处理
服务端 ──TCP──▶ 网络包2: '力数据分析助手"}}]}\n\ndata: [DONE]\n\n'
buf 拼齐 → split 得两行 →
行1 = data: {...content:"风力数据分析助手"} → JSON.parse → 写出"风力数据分析助手"
行2 = data: [DONE] → continue 跳过
服务端 ──关闭──▶ read() 返回 {done:true} → break → 输出换
javascript
/**
* ============================================================================
* chat_stream_enhanced.mjs
* ----------------------------------------------------------------------------
* 更完整的「流式对话」示例 ------ 通义千问 DashScope OpenAI 兼容接口
*
* 相比 first_strem.mjs 新增的能力:
* 1. 请求超时(AbortController),防止接口卡死一直等
* 2. 完整错误处理:HTTP 非 2xx、网络错误、流中断、超时
* 3. 补齐模型可选参数:temperature / max_tokens / top_p / stop / penalties
* 4. 流式解析更健壮:兼容 \r\n、跨网络包断行、多字节中文、[DONE]
* 5. 记录 finish_reason 与 token 用量(usage),方便统计成本
* 6. 同时提供「非流式」版函数,方便对照两种响应的差异
*
* 运行方式:
* export QW_API_KEY=你的key # 也可以用 QW_MODEL 覆盖模型
* node chat_stream_enhanced.mjs
* ============================================================================
*/
// ---------------------------------------------------------------------------
// 一、配置区:所有可调的东西集中放在这里,代码主体不动它
// ---------------------------------------------------------------------------
// 从环境变量读取,避免把密钥写进代码(也支持默认值兜底)
const CONFIG = {
// API 地址:OpenAI 兼容模式固定拼 /chat/completions
apiUrl: process.env.QW_API_URL ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: process.env.QW_API_KEY ?? "",
model: process.env.QW_MODEL ?? "qwen3.8-max", // 换成你的模型 id,如 qwen-plus / qwen-max
// 下面这些是「模型行为」参数,都对应服务端默认值;你可以在请求体里覆盖
temperature: 0.7, // 越高越发散、越低越保守。做事实问答建议 0.2~0.5
topP: 1, // 核采样,一般与 temperature 二选一调,别同时动
maxTokens: null, // 单次回答最大长度;null = 不限制(走模型上限)
stop: null, // 遇到这些字符串就停止生成,最多 4 个
presencePenalty: 0, // 越高越鼓励聊新话题
frequencyPenalty: 0, // 越高越避免重复用词
// 请求控制
timeoutMs: 60_000, // 超过 60s 强制取消请求
stream: true, // 是否流式;改成 false 可对比非流式行为
includeUsage: true, // 流式结束时返回 token 用量(部分兼容网关可能不支持)
};
// 校验:没配 key 就直接退出并提示(fail fast,比跑到一半才 401 好)
if (!CONFIG.apiKey) {
console.error("[错误] 未设置 QW_API_KEY 环境变量,请先执行: export QW_API_KEY=你的key");
process.exit(1);
}
// ---------------------------------------------------------------------------
// 二、工具函数:构造请求体
// body 里很多字段在服务端都有默认值,这里按 CONFIG 显式传入,
// 好处是参数一目了然,改一个地方全文件生效。
// ---------------------------------------------------------------------------
/**
* 把模型行为参数 + 对话历史打包成发给 /chat/completions 的 body
* @param {Array} messages 形如 [{role:'system',content:'...'},{role:'user',content:'...'}]
* @param {Object} [overrides] 临时覆盖 CONFIG 的参数(可选)
* @returns {Object} 请求体
*/
function buildRequestBody(messages, overrides = {}) {
const body = {
model: CONFIG.model, // 用哪个模型(必填)
messages, // 对话上下文,顺序即记忆顺序(必填)
stream: overrides.stream ?? CONFIG.stream,
// ---- 以下为可选参数,均显式给出(未写的就靠注释说明默认值) ----
temperature: overrides.temperature ?? CONFIG.temperature,
top_p: overrides.topP ?? CONFIG.topP,
presence_penalty: overrides.presencePenalty ?? CONFIG.presencePenalty,
frequency_penalty: overrides.frequencyPenalty ?? CONFIG.frequencyPenalty,
};
// maxTokens / stop 允许配置成 null 表示「不传」,否则服务端会收下这两个字段
if (CONFIG.maxTokens != null) body.max_tokens = CONFIG.maxTokens;
if (CONFIG.stop != null) body.stop = CONFIG.stop;
// 流式结尾多带一个 usage(token 统计)字段。注意:部分第三方兼容网关可能不认识它,被忽略也无害
if (body.stream && CONFIG.includeUsage) {
body.stream_options = { include_usage: true };
}
return body;
}
// ---------------------------------------------------------------------------
// 三、核心请求函数(统一收尾):
// 超时、错误、网络异常、HTTP 状态码,全在这里处理完,业务代码只需关心结果
// ---------------------------------------------------------------------------
/**
* 发送一次请求并返回 Response;负责:非 2xx 错误解析、超时取消、网络错误提示
* @returns {Promise<Response>}
*/
async function sendRequest(body) {
const controller = new AbortController();
// setTimeout 到时后 abort(),fetch 会以 AbortError 拒绝,从而实现「超时取消」
const timer = setTimeout(() => controller.abort(), CONFIG.timeoutMs);
// 计时器不让 Node 进程多等:请求结束后进程能否退出由别的东西决定
timer.unref?.();
try {
const res = await fetch(`${CONFIG.apiUrl}/chat/completions`, {
method: "POST", // 生成动作必须 POST
headers: {
// 声明 body 是 JSON(不写会自动变 text/plain,接口会 400)
"Content-Type": "application/json",
// Bearer 令牌认证,密钥从环境变量来
"Authorization": `Bearer ${CONFIG.apiKey}`,
// 声明我们接受流式返回,服务端按此决定响应分块方式
"Accept": "text/event-stream",
},
body: JSON.stringify(body), // JS 对象 -> JSON 字符串才能上网络
signal: controller.signal, // 接上超时信号
});
// ---- 非 2xx:说明请求被服务端拒绝,尽量把原因捞出来 ----
if (!res.ok) {
let detail = `HTTP ${res.status} ${res.statusText}`;
try {
// OpenAI 兼容接口的错误通常是 { error: { message, type } }
const data = await res.json();
detail = data?.error?.message ?? data?.error ?? JSON.stringify(data);
} catch { /* 响应体不是 JSON 就忽略,用状态码兜底 */ }
// 4xx 通常是参数/key 问题,5xx 是服务端问题------给不同提示方便排查
const kind = res.status >= 500 ? "服务端异常(请稍后重试)" : "请求被拒绝(请检查 key/参数)";
throw new Error(`[${kind}] ${detail}`);
}
return res;
} catch (err) {
// 区分「超时」和「网络层错误」,它们的处理方式完全不同
if (err.name === "AbortError") {
throw new Error(`[超时] 请求超过 ${CONFIG.timeoutMs / 1000}s 未返回,已取消`);
}
if (err instanceof TypeError) {
// fetch 在 DNS 失败/连接被拒等情况下抛 TypeError,跟业务错误区分开
throw new Error(`[网络错误] 无法连接到 ${CONFIG.apiUrl}:${err.message}`);
}
throw err; // 上面构造的业务错误原样抛给调用方
} finally {
clearTimeout(timer); // 无论成功失败都清掉计时器
}
}
// ---------------------------------------------------------------------------
// 四、流式响应解析器:把 SSE 文本流翻译成一段段文字
// SSE 协议要点(为什么这么写):
// - 数据按行传输,每行以 \n 或 \r\n 结尾
// - 有效内容形如 data: {"choices":[...]},最后一条是 data: [DONE]
// - 网络包可能把一个事件拆成两半发来,所以必须缓存「不完整的一行」
// ---------------------------------------------------------------------------
/**
* 读取流式响应,边读边把增量内容打印出来,最后汇总成完整回答
* @returns {Promise<{text:string, usage:object|null, finishReason:string|null}>}
*/
async function readStream(res) {
const reader = res.body.getReader();
// TextDecoder 用 stream:true 逐段解码,避免一个汉字被拆到两个网络包时出现乱码
const decoder = new TextDecoder("utf-8");
let buffer = ""; // 攒着没拼完整的一行
let text = ""; // 完整回答
let usage = null; // 最后的 token 统计
let finishReason = null; // 结束原因:stop | length | content_filter ...
// 从一行 SSE 里提取 delta 内容并打印
const handleDataPayload = (payload) => {
if (payload === "[DONE]") return; // 服务端发来的结束哨兵,这里不处理
try {
const json = JSON.parse(payload); // 每个 data: 后都是完整 JSON
// 流式增量内容在 choices[0].delta.content;最后一个包可能只有 finish_reason
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
process.stdout.write(delta); // 不打换行 = 打字机效果
text += delta;
}
// 结束包会带 finish_reason;usage 单独放在 choices 为空的那个包(需 include_usage)
if (json.choices?.[0]?.finish_reason) finishReason = json.choices[0].finish_reason;
if (json.usage) usage = json.usage;
} catch (err) {
// 个别网关会夹带非 JSON 注释行,忽略即可,不必中断整个流
console.error("\n[解析警告] 跳过无法解析的行:", payload.slice(0, 80));
}
};
while (true) {
const { done, value } = await reader.read(); // value 是 Uint8Array 一个网络包
if (done) break;
// 先解码再按行切;stream:true 让解码器暂存可能被切断的多字节字符
buffer += decoder.decode(value, { stream: true });
// 把 buffer 按换行切成「完整行 + 尾巴(可能残缺)」
const lines = buffer.split("\n");
buffer = lines.pop(); // 最后一段常常是半行,留到下次拼接,这是流式解析的关键
for (const line of lines) {
const trimmed = line.replace(/\r$/, ""); // 兼容 Windows 的 \r\n
if (!trimmed.startsWith("data:")) continue; // SSE 只认 data: 开头的行
handleDataPayload(trimmed.slice(5).trim()); // 去掉 "data:" 再 trim
}
}
// 循环结束后可能还剩最后半行没换行(部分实现最后一包无 \n)
if (buffer.trim()) {
const last = buffer.trim();
if (last.startsWith("data:")) handleDataPayload(last.slice(5).trim());
}
return { text, usage, finishReason };
}
// ---------------------------------------------------------------------------
// 五、对外两个函数:
// streamChat -> 流式(打字机效果)
// chatOnce -> 非流式(一次性拿完整 JSON),用于对照学习
// ---------------------------------------------------------------------------
/**
* 流式对话:边生成边打印,返回完整文本和统计信息
* @param {Array} messages 对话消息
* @returns {Promise<{text, usage, finishReason, elapsedMs}>}
*/
async function streamChat(messages) {
const startedAt = Date.now();
const body = buildRequestBody(messages, { stream: true });
const res = await sendRequest(body);
const { text, usage, finishReason } = await readStream(res);
process.stdout.write("\n"); // 流结束后补一个换行
return { text, usage, finishReason, elapsedMs: Date.now() - startedAt };
}
/**
* 非流式对话:一次等全部返回。stream:false 时响应就是普通 JSON 而不是 SSE
* 对比点:body 里没有 stream_options,内容是 res.json() 而不用 readStream
*/
async function chatOnce(messages) {
const startedAt = Date.now();
const body = buildRequestBody(messages, { stream: false });
const res = await sendRequest(body);
const json = await res.json(); // 非流式:body 直接是完整 JSON,读一次即可
const content = json.choices?.[0]?.message?.content ?? ""; // 非流式内容在 message.content
return {
text: content,
usage: json.usage ?? null,
finishReason: json.choices?.[0]?.finish_reason ?? null,
elapsedMs: Date.now() - startedAt,
};
}
// ---------------------------------------------------------------------------
// 六、main:示例对话(保留课程里的 RAG 主题问题)
// 想改成多轮对话,就往 messages 里按 user/assistant 顺序不断追加即可
// ---------------------------------------------------------------------------
async function main() {
const messages = [
{
role: "system",
content:
"你是电力数据分析助手,回答简洁。\n" +
// ↓ 模拟 RAG:把检索到的「私有文档片段」拼进 system 提示,模型就能据此作答
"[私有资料] 某风电场 2026-08 故障次数:齿轮箱 3 次、变流器 5 次、叶片 1 次。",
},
{
role: "user",
content: "用一句话解释:为什么 RAG 能让模型回答私有文档问题?并顺便回答:本月变流器故障几次?",
},
];
console.log("模型:", CONFIG.model);
console.log("模式:", CONFIG.stream ? "流式 (stream)" : "非流式 (chatOnce)");
console.log("----------------------------------------\n");
// 同一个 messages,两种调用方式都演示一遍,方便对比
const result = CONFIG.stream
? await streamChat(messages)
: await chatOnce(messages);
console.log("\n----------------------------------------");
console.log("完整回答:", result.text);
console.log("结束原因:", result.finishReason ?? "(无)");
if (result.usage) {
console.log(`Token 用量 → 输入 ${result.usage.prompt_tokens} / 输出 ${result.usage.completion_tokens} / 总计 ${result.usage.total_tokens}`);
}
console.log(`耗时: ${result.elapsedMs} ms`);
}
// 顶层 await 语法(ESM 特性),出错时给出不崩溃的兜底提示
try {
await main();
} catch (err) {
console.error("\n[运行失败]", err.message ?? err);
process.exitCode = 1; // 非零退出码方便脚本/CI 判断失败
}