fix(AI): 流式调用加空闲超时、客户端断开即停上游、错误原因不下发

- streamChat 加 45 秒空闲超时(每收到一块重新计时,不掐长回答),原来 provider
  卡住这条请求就永远挂着
- 加 cancel():客户端断开时 abort 上游 fetch,原来会把 provider 的回答读完白烧 token,
  还把「客户端走了」记成 AI 失败;现在留痕写「客户端已断开」
- 推给前端的 error 事件改成固定中文文案(缺 AI_KEY 时是「AI 功能未开启」)。原来
  error.message 原样下发,里面可能是 provider 的 HTTP 响应体或数据库报错;
  真实原因只进 console.error 和 onError 留痕

用假 provider 实跑核过:正常流式、客户端 1 秒断开时上游被取消、provider 500 时
客户端只拿到固定文案(响应体只在日志里)、provider 挂住 45 秒后收到 error + end。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:01:43 +08:00
1 parent 04c0ccd304
commit 09030240b8
1 file changed
+68 -13
+68 -13
View File
@@ -19,11 +19,29 @@ function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
/**
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
* 客户端断开本来就能收尾。
* 学生那边的按钮也就一直转。
*/
const COMPLETE_TIMEOUT_MS = 60_000
/**
* 流式调用的**空闲**超时:连续这么久没收到一个字节就放弃。不用总时长超时 —— 那会把
* 正在推的长回答直接掐断;空闲超时每收到一块就重新计时,只拦「provider 卡住不动」。
* 首字节也算在内(DeepSeek 首 token 通常几秒,给足余量)。
*/
const STREAM_IDLE_TIMEOUT_MS = 45_000
/**
* 推给前端的错误文案。真实原因(provider 的 HTTP 响应体、数据库报错……)只进日志和
* `onError` 留痕,**不下发**:学生看不懂,也不该看到上游的响应内容。
*/
const CLIENT_ERROR_MESSAGE = "AI 服务暂时不可用,请稍后再试"
const MISSING_KEY_MESSAGE = "AI 功能未开启"
/** 给客户端的错误事件;原因另行记日志 */
function errorEvent(message: string) {
return `data: ${JSON.stringify({ type: "error", message })}\n\n`
}
export async function completeChat(
system: string,
user: string,
@@ -81,23 +99,42 @@ export function streamChat(
hooks.onError?.(message).catch((error) => {
console.error("streamChat onError hook failed", error)
})
// 客户端断开(cancel)和空闲超时都走这一个 controller,把上游的 fetch / read 一起停掉。
// 原来客户端走了之后这里还在把 provider 的回答一块块读完,白烧 token
const upstream = new AbortController()
let closed = false
let idleTimer: ReturnType<typeof setTimeout> | undefined
const touch = () => {
clearTimeout(idleTimer)
idleTimer = setTimeout(
() => upstream.abort(new Error("AI provider stream idle timeout")),
STREAM_IDLE_TIMEOUT_MS,
)
}
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value))
const send = (value: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(value))
} catch {
closed = true
}
}
if (!config.aiKey) {
await reportError("缺少 AI_KEY")
send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
)
send(errorEvent(MISSING_KEY_MESSAGE))
send("event: end\n\n")
controller.close()
if (!closed) controller.close()
return
}
try {
touch()
const response = await fetch(
new URL("/chat/completions", config.aiBaseUrl),
{
method: "POST",
signal: upstream.signal,
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
@@ -123,6 +160,7 @@ export function streamChat(
let buffer = ""
const chunks: string[] = []
while (true) {
touch()
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split("\n")
@@ -157,15 +195,25 @@ export function streamChat(
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
// 客户端主动走掉不算 AI 失败,但照样留痕:这条分析没有落库
const message = closed
? "客户端已断开"
: error instanceof Error
? error.message
: String(error)
if (!closed) console.error("streamChat failed", error)
await reportError(message)
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
send(errorEvent(CLIENT_ERROR_MESSAGE))
} finally {
clearTimeout(idleTimer)
send("event: end\n\n")
controller.close()
if (!closed) controller.close()
}
},
cancel() {
closed = true
upstream.abort(new Error("client disconnected"))
},
})
return new Response(body, {
headers: {
@@ -222,11 +270,18 @@ export function streamWhole(
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端
console.error("streamWhole failed", error)
// 先留痕再回前端;前端只拿固定文案,原因见 CLIENT_ERROR_MESSAGE
await hooks.onError?.(message).catch((e) => {
console.error("streamWhole onError hook failed", e)
})
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
send(
errorEvent(
message === "缺少 AI_KEY"
? MISSING_KEY_MESSAGE
: CLIENT_ERROR_MESSAGE,
),
)
} finally {
clearInterval(heartbeat)
send("event: end\n\n")