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:
1 parent
04c0ccd304
commit
09030240b8
1 file changed
+68
-13
+68
-13
@@ -19,11 +19,29 @@ function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
||||||
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
|
* 学生那边的按钮也就一直转。
|
||||||
* 客户端断开本来就能收尾。
|
|
||||||
*/
|
*/
|
||||||
const COMPLETE_TIMEOUT_MS = 60_000
|
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(
|
export async function completeChat(
|
||||||
system: string,
|
system: string,
|
||||||
user: string,
|
user: string,
|
||||||
@@ -81,23 +99,42 @@ export function streamChat(
|
|||||||
hooks.onError?.(message).catch((error) => {
|
hooks.onError?.(message).catch((error) => {
|
||||||
console.error("streamChat onError hook failed", 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>({
|
const body = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
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) {
|
if (!config.aiKey) {
|
||||||
await reportError("缺少 AI_KEY")
|
await reportError("缺少 AI_KEY")
|
||||||
send(
|
send(errorEvent(MISSING_KEY_MESSAGE))
|
||||||
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
|
||||||
)
|
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
controller.close()
|
if (!closed) controller.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
touch()
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
new URL("/chat/completions", config.aiBaseUrl),
|
new URL("/chat/completions", config.aiBaseUrl),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal: upstream.signal,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: `Bearer ${config.aiKey}`,
|
authorization: `Bearer ${config.aiKey}`,
|
||||||
@@ -123,6 +160,7 @@ export function streamChat(
|
|||||||
let buffer = ""
|
let buffer = ""
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
while (true) {
|
while (true) {
|
||||||
|
touch()
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
buffer += decoder.decode(value, { stream: !done })
|
buffer += decoder.decode(value, { stream: !done })
|
||||||
const lines = buffer.split("\n")
|
const lines = buffer.split("\n")
|
||||||
@@ -157,15 +195,25 @@ export function streamChat(
|
|||||||
: undefined
|
: undefined
|
||||||
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
// 客户端主动走掉不算 AI 失败,但照样留痕:这条分析没有落库
|
||||||
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
|
const message = closed
|
||||||
|
? "客户端已断开"
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error)
|
||||||
|
if (!closed) console.error("streamChat failed", error)
|
||||||
await reportError(message)
|
await reportError(message)
|
||||||
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
send(errorEvent(CLIENT_ERROR_MESSAGE))
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(idleTimer)
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
controller.close()
|
if (!closed) controller.close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
cancel() {
|
||||||
|
closed = true
|
||||||
|
upstream.abort(new Error("client disconnected"))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -222,11 +270,18 @@ export function streamWhole(
|
|||||||
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
// 先留痕再回前端
|
console.error("streamWhole failed", error)
|
||||||
|
// 先留痕再回前端;前端只拿固定文案,原因见 CLIENT_ERROR_MESSAGE
|
||||||
await hooks.onError?.(message).catch((e) => {
|
await hooks.onError?.(message).catch((e) => {
|
||||||
console.error("streamWhole onError hook failed", 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 {
|
} finally {
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
|
|||||||
Reference in new issue
Block a user