From ee4899fa662d87f8405ca699417c298c32a864ba Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:42:01 +0800 Subject: [PATCH 1/2] Minimum speed: judge only visible, flowing generation Follow-ups to the minimum generation speed check: - Gemini: the streamed output count no longer includes hidden reasoning (thoughtsTokenCount). A stream that reported only reasoning made the live speed and the judged speed 0 until the text estimate caught up. - Reasoning that arrives as a summary (Anthropic, Gemini, Bedrock, the Responses reasoning summary) is not judged while it lasts and its time is taken out of the window afterwards. Raw reasoning (Chat reasoning_content, Responses reasoning_text) is judged as before. Pace calibration no longer compares summary text with the full reasoning count. - Nothing is judged while the stream is paused (no content for more than a second), and the silence threshold is min(5 s, window / 4), so a pause before a burst of tool arguments no longer counts as slow. - Nothing is judged once the answer has ended (stop reason seen or the final frame delivered). - The health failure and the speed on the record are applied only when the final outcome is still the speed cut, and the outcome is reported before the held-back tail is sent, so record, health and what the client sees agree. - The upstream stream and its slot are released as soon as reading stops (also for idle timeout and abort). - A manual abort wins over a speed judgment due at the same moment. - Upstream checkup and /token-rate medians leave out failed rows, whose speed (the judged window) is a different measure. - Docs: Gemini streams without alt=sse are not judged. Co-Authored-By: Claude Opus 5.5 --- crates/tw-api/src/lib.rs | 13 +- crates/tw-dialect/src/convert.rs | 18 ++ crates/tw-dialect/src/responses/stream.rs | 9 + crates/tw-gateway/src/ending.rs | 227 ++++++++++++++++-- .../tw-gateway/src/server/pipeline/relay.rs | 66 +++-- crates/tw-gateway/src/slow.rs | 131 ++++++++-- crates/tw-gateway/tests/min_speed.rs | 110 +++++++++ crates/tw-store/src/db.rs | 2 +- crates/tw-store/src/health.rs | 17 +- docs/config.md | 16 +- docs/config.zh-CN.md | 8 +- 11 files changed, 549 insertions(+), 68 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index e0bb0f04..c0281367 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -1135,7 +1135,9 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// **58 起上游可以设最低生成速度**(配置的 `providers[].min_speed`):[`ProviderView`] 多了 /// `min_speed`([`MinSpeedView`],不管是空),[`ProviderInput`] 多了 `min_speed` /// ([`MinSpeedInput`],不给就是不管)。流式回答从第一个 token 起持续低于它(最近 `window_secs` -/// 秒的平均速度,中途 5 秒以上的静默不算),回答以一条 503 的错误结束(Anthropic 的 +/// 秒的平均速度;中途 5 秒以上、判定时长不到 20 秒的按它的四分之一以上的静默不算,停着的时候、 +/// 摘要式的推理进行着的时候、回答说完之后不判断;不带 `alt=sse` 的 Gemini 流不管),回答以一条 +/// 503 的错误结束(Anthropic 的 /// `overloaded_error`、Responses 的 `response.failed` / `server_error`、Gemini 的 `UNAVAILABLE`), /// 请求失败,来源 `upstream`,码 `gw.upstream.too_slow`(`upstream`、`rate`、`min`、`secs`),这家 /// 记一次失败。[`Event::RequestFailed`] 多了 `tokens_per_sec`:只有这种失败带着,是结束那一刻的 @@ -3727,9 +3729,12 @@ pub struct ProviderInput { /// 一家上游的最低生成速度(配置的 `providers[].min_speed`)。 /// /// 流式回答从第一个 token 起,每秒看一次最近 `window_secs` 秒的平均生成速度(和 -/// [`RunningView::tokens_per_sec`] 同一个估法;中途连续 5 秒以上没有内容的那一段不算),低于 -/// `tokens_per_sec` 就结束回答:客户端收到一条 503 的错误(可以重试),请求失败,码是 -/// `gw.upstream.too_slow`,这家记一次失败。非流式的回答、WebSocket 上的不管 +/// [`RunningView::tokens_per_sec`] 同一个估法;中途连续 5 秒以上(判定时长不到 20 秒的,它的 +/// 四分之一以上)没有内容的那一段不算),低于 `tokens_per_sec` 就结束回答:客户端收到一条 503 的 +/// 错误(可以重试),请求失败,码是 `gw.upstream.too_slow`,这家记一次失败。停着(上一段内容 +/// 是一秒多以前的)、摘要式的推理(Anthropic、Gemini、Bedrock 的推理,Responses 的推理摘要) +/// 进行着、回答说完之后都不判断,推理的那段时间之后也不算。非流式的回答、WebSocket 上的、 +/// 不带 `alt=sse` 的 Gemini 流(JSON 数组)不管 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct MinSpeedView { diff --git a/crates/tw-dialect/src/convert.rs b/crates/tw-dialect/src/convert.rs index fc047335..2d8abd98 100644 --- a/crates/tw-dialect/src/convert.rs +++ b/crates/tw-dialect/src/convert.rs @@ -525,6 +525,18 @@ impl Parser { } } + /// 看得见的推理是不是摘要式的:隔几秒一段,不代表模型想得多快。Anthropic(Claude 4 起)、 + /// Gemini 的思考摘要、Bedrock 上的 Claude 是;OpenAI Chat 的 `reasoning_content` 是原样 + /// 流出来的;Responses 看它发的是摘要还是原文。**拿不准的按摘要算**:转发 Anthropic 格式的 + /// 开源模型推理其实是原样的,按摘要算只是少判断一段,不会冤枉它 + fn summarizes_reasoning(&self) -> bool { + match self { + Parser::Chat(_) => false, + Parser::Responses(p) => p.summarizes_reasoning(), + Parser::Anthropic(_) | Parser::Gemini(_) | Parser::Bedrock(_) => true, + } + } + fn finish(&mut self, out: &mut Vec) { match self { Parser::Anthropic(_) => {} @@ -706,6 +718,12 @@ impl Reader { out } + /// 这条流里看得见的推理是不是摘要式的(隔几秒一段,见 `Parser::summarizes_reasoning`)。 + /// Responses 要读到推理的字才知道 + pub fn summarizes_reasoning(&self) -> bool { + self.parser.summarizes_reasoning() + } + /// 流结束了:最后一帧后面不带空行的上游也要读到(见 [`frame::Decoder::flush`]) pub fn finish(&mut self) -> Vec { let frames = self.decoder.flush(); diff --git a/crates/tw-dialect/src/responses/stream.rs b/crates/tw-dialect/src/responses/stream.rs index b44c8e6c..811ee293 100644 --- a/crates/tw-dialect/src/responses/stream.rs +++ b/crates/tw-dialect/src/responses/stream.rs @@ -44,9 +44,17 @@ pub struct Parser { texts: HashMap<(u64, u64), (usize, bool)>, next: usize, tool_call: bool, + /// 推理是原样流出来的(`response.reasoning_text.delta`,开源模型),不是摘要 + raw_reasoning: bool, } impl Parser { + /// 看得见的推理是摘要(`reasoning_summary_text`):隔几秒一段,不代表模型想得多快。见过 + /// 原样的推理(`reasoning_text`)就不是 + pub(crate) fn summarizes_reasoning(&self) -> bool { + !self.raw_reasoning + } + fn open(&mut self, kind: BlockKind, out: &mut Vec) -> usize { let index = self.next; self.next += 1; @@ -162,6 +170,7 @@ impl Parser { self.close_text(oi, ci, out); } "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => { + self.raw_reasoning |= kind == "response.reasoning_text.delta"; let summary_index = u64_of(&v, "summary_index"); let Some(it) = self.items.get_mut(&oi) else { return; diff --git a/crates/tw-gateway/src/ending.rs b/crates/tw-gateway/src/ending.rs index cb5b8cd8..a9f96ea5 100644 --- a/crates/tw-gateway/src/ending.rs +++ b/crates/tw-gateway/src/ending.rs @@ -221,13 +221,19 @@ struct Stream { opened: bool, /// 第一个 token 起的正文、推理文字、工具参数 text: tw_engine::TextTokens, + /// 其中的正文和工具参数(没有推理):推理是摘要时拿它校准(见 [`Stream::learn`]) + answer: tw_engine::TextTokens, + /// 见过摘要式的推理(见 [`tw_dialect::convert::Reader::summarizes_reasoning`]) + summarized: bool, /// 一个推理块开了头还没见到字(见 [`Stream::count`]) thought_pending: bool, /// 有过一个字都没有的推理块:上游报的输出里有看不见的推理,不拿来校准 thought_hidden_later: bool, /// 见过推理的字 thought_text: bool, - /// 上游在流里报的累计输出数(Gemini) + /// 上游在流里报的累计输出数(Gemini 的 `candidatesTokenCount`)。**不含推理**:看不见的推理 + /// 不是生成出来给人看的,有的上游流里只报推理数、不报正文数,算进来的话正文的估数要追上它 + /// 之前速度一直是 0 reported: u64, /// 已经记到速度表上的 added: u64, @@ -244,15 +250,21 @@ struct Stream { } impl Stream { - /// 第一个 token 之后的一个事件:输出的字数进账,Gemini 报的累计数记下 - fn count(&mut self, e: &ir::Event) { + /// 第一个 token 之后的一个事件(`now` 到的):输出的字数进账,Gemini 报的累计数记下。 + /// `summarizes` 说看得见的推理是不是摘要式的:是的话推理进行着的那段时间不判断速度(见 + /// [`crate::slow`]) + fn count(&mut self, e: &ir::Event, now: Instant, summarizes: bool) { match e { ir::Event::Delta { delta: ir::Delta::Text(t) | ir::Delta::ToolInput(t), .. } => { self.text.add(t); - self.fresh |= !t.is_empty(); + self.answer.add(t); + if !t.is_empty() { + self.fresh = true; + self.gauge_do(|g| g.resume(now)); + } } ir::Event::Delta { delta: ir::Delta::Thinking(t), @@ -263,27 +275,53 @@ impl Stream { self.thought_text = true; self.thought_pending = false; self.fresh = true; + if summarizes { + self.summarized = true; + self.gauge_do(|g| g.pause(now)); + } else { + self.gauge_do(|g| g.resume(now)); + } } } ir::Event::BlockStart { kind: ir::BlockKind::ToolCall { .. }, .. - } => self.fresh = true, + } => { + self.fresh = true; + self.gauge_do(|g| g.resume(now)); + } ir::Event::BlockStart { kind: ir::BlockKind::Thinking, .. - } => self.thought_pending = true, - ir::Event::BlockStop { .. } if self.thought_pending => { - self.thought_pending = false; - self.thought_hidden_later = true; + } => { + self.thought_pending = true; + if summarizes { + self.gauge_do(|g| g.pause(now)); + } + } + ir::Event::BlockStop { .. } => { + if self.thought_pending { + self.thought_pending = false; + self.thought_hidden_later = true; + } + // 推理块收了:回答接着出来 + self.gauge_do(|g| g.resume(now)); } ir::Event::Usage(u) if self.upstream == ir::Dialect::Gemini => { - self.reported = self.reported.max(u.output); + self.reported = self.reported.max(u.output.saturating_sub(u.reasoning)); } + // 回答说完了:之后不再判断速度(有的上游说完了过一阵才关连接) + ir::Event::Stop(_) => self.gauge_do(crate::slow::Gauge::end), _ => {} } } + fn gauge_do(&mut self, f: impl FnOnce(&mut crate::slow::Gauge)) { + if let Some(g) = self.gauge.as_mut() { + f(g); + } + } + /// 这一块读完了:估到目前为止一共多少,多出来的记到速度表上。开口那一刻哪怕估出 0 个 /// 也记一笔 —— 速度从那一刻算起 /// @@ -309,15 +347,30 @@ impl Stream { } /// 结束了,上游报了用量:拿它校准这家这个模型的估法。**有看不见的推理就不校**:上游报 - /// 的输出里有推理 token,流里却没有它们的字,比出来的比例会偏高 + /// 的输出里有推理 token,流里却没有它们的字,比出来的比例会偏高。 + /// + /// **推理是摘要的也一样**:流里的是几句摘要,上游报的推理数是整段推理,拿它们比,比例被 + /// 顶到上限。上游分开报了推理数的,只拿正文和工具参数去比;没分开报的(Anthropic)不校 fn learn(&self, model: &str, usage: &Usage) { let hidden = self.hidden_thought || self.thought_hidden_later || (usage.reasoning > 0 && !self.thought_text); - if self.opened && !hidden { - self.pace - .learn(&self.provider, model, self.text.tokens(), usage.output); + if !self.opened || hidden { + return; } + if self.summarized { + if usage.reasoning > 0 { + self.pace.learn( + &self.provider, + model, + self.answer.tokens(), + usage.output.saturating_sub(usage.reasoning), + ); + } + return; + } + self.pace + .learn(&self.provider, model, self.text.tokens(), usage.output); } } @@ -406,6 +459,8 @@ impl Ending { hidden_thought: false, opened: false, text: Default::default(), + answer: Default::default(), + summarized: false, thought_pending: false, thought_hidden_later: false, thought_text: false, @@ -593,7 +648,10 @@ impl Ending { return; }; let mut opened = None; - for e in f.reader.feed(chunk) { + let now = Instant::now(); + let events = f.reader.feed(chunk); + let summarizes = f.reader.summarizes_reasoning(); + for e in events { if !f.opened { match opening(&e) { Some(with_thought) => { @@ -607,7 +665,7 @@ impl Ending { None => continue, } } - f.count(&e); + f.count(&e, now, summarizes); } f.tally(); let Some((with_thought, hidden_thought)) = opened else { @@ -1879,3 +1937,140 @@ mod tests { ); } } + +/// 判定窗口和实时速度怎么读推理(见 [`crate::slow`]) +#[cfg(test)] +mod speed_tests { + use super::*; + use std::time::Duration; + + fn streaming(dialect: ir::Dialect, pace: &std::sync::Arc) -> Ending { + let bus = tw_observe::EventBus::new(); + let mut e = Ending::new(bus, 7, "m".into(), Instant::now(), 1_000, None); + e.responded(200); + e.streaming(dialect, "up", pace); + // 一「秒」10 毫秒:窗口 200 毫秒,长静默 50 毫秒 + e.gauge(crate::slow::Gauge::new(20, Duration::from_millis(10))); + e + } + + /// 每隔几毫秒喂一段,喂满 250 毫秒(比窗口长)。计时粗的机器上一隔十几毫秒,也比长静默短 + fn keep_feeding(e: &mut Ending, chunk: &str) { + let t = Instant::now(); + while t.elapsed() < Duration::from_millis(250) { + e.feed(chunk.as_bytes()); + std::thread::sleep(Duration::from_millis(5)); + } + e.feed(chunk.as_bytes()); + } + + fn added(e: &Ending) -> u64 { + e.stream.as_ref().unwrap().added + } + + /// 40 个 ASCII 字符:估出来 10 个 + const TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmn"; + + /// Gemini 流里只报了推理数(`thoughtsTokenCount`)、没报正文数:看不见的推理不算进生成量。 + /// 算进去的话一开口就记上几千个,之后正文的估数追上它之前一直是 0 + #[test] + fn hidden_gemini_reasoning_does_not_drive_the_speed() { + let pace = std::sync::Arc::new(crate::pace::Pace::new()); + let mut e = streaming(ir::Dialect::Gemini, &pace); + let chunk = format!( + "data: {{\"candidates\":[{{\"content\":{{\"role\":\"model\",\"parts\":[{{\"text\":\"{TEXT}\"}}]}}}}],\"usageMetadata\":{{\"promptTokenCount\":10,\"thoughtsTokenCount\":5000}}}}\r\n\r\n" + ); + for n in 1..=3 { + e.feed(chunk.as_bytes()); + assert_eq!(added(&e), 10 * n); + } + } + + /// 摘要式的推理(Anthropic 的 thinking)进行着的时候不判断;原样的推理(Chat 的 + /// `reasoning_content`)照常判断 + #[test] + fn summarised_reasoning_is_not_judged_while_it_lasts_and_raw_reasoning_is() { + let pace = std::sync::Arc::new(crate::pace::Pace::new()); + let mut e = streaming(ir::Dialect::Anthropic, &pace); + e.feed(b"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n"); + let thought = format!( + "event: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"thinking_delta\",\"thinking\":\"{TEXT}\"}}}}\n\n" + ); + keep_feeding(&mut e, &thought); + assert_eq!(e.pace_at(Instant::now()), None, "推理进行着"); + + let mut e = streaming(ir::Dialect::Chat, &pace); + let raw = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"reasoning_content\":\"{TEXT}\"}}}}]}}\n\n" + ); + keep_feeding(&mut e, &raw); + assert!(e.pace_at(Instant::now()).is_some(), "原样的推理照常判断"); + } + + /// 推理是摘要的:不拿几句摘要去比整段推理。上游分开报了推理数的只拿正文去比,没分开报的 + /// 不校。推理在第一个 token 之后(穿插在回答中间)时才会走到校准 + #[test] + fn a_reasoning_summary_does_not_skew_the_calibration() { + fn ev(kind: &str, body: serde_json::Value) -> String { + let mut b = body; + b["type"] = serde_json::json!(kind); + format!("event: {kind}\ndata: {b}\n\n") + } + let answer = TEXT.repeat(5); + // Responses:正文、推理摘要、正文;上游说输出 1000 个 + let pace = std::sync::Arc::new(crate::pace::Pace::new()); + let mut e = streaming(ir::Dialect::Responses, &pace); + let text = |oi: u64| { + [ + ev("response.output_item.added", serde_json::json!({"output_index": oi, "item": {"type": "message", "id": format!("msg_{oi}")}})), + ev("response.content_part.added", serde_json::json!({"output_index": oi, "content_index": 0, "part": {"type": "output_text", "text": ""}})), + ev("response.output_text.delta", serde_json::json!({"output_index": oi, "content_index": 0, "delta": answer})), + ev("response.output_text.done", serde_json::json!({"output_index": oi, "content_index": 0, "text": answer})), + ev("response.output_item.done", serde_json::json!({"output_index": oi, "item": {"type": "message"}})), + ] + .concat() + }; + let s = [ + ev("response.created", serde_json::json!({"response": {"id": "resp_1", "model": "m"}})), + text(0), + ev("response.output_item.added", serde_json::json!({"output_index": 1, "item": {"type": "reasoning", "id": "rs_1", "summary": []}})), + ev("response.reasoning_summary_text.delta", serde_json::json!({"output_index": 1, "summary_index": 0, "delta": TEXT})), + ev("response.output_item.done", serde_json::json!({"output_index": 1, "item": {"type": "reasoning", "id": "rs_1"}})), + text(2), + ev("response.completed", serde_json::json!({"response": {"usage": {"input_tokens": 10, "output_tokens": 1000, "output_tokens_details": {"reasoning_tokens": 850}}}})), + ] + .concat(); + e.feed(s.as_bytes()); + e.finished(200); + // 嗅到的用量里推理数没分开(`reasoning` 是 0):说不出正文是多少,不校。拿正文加几句摘要 + // 的 110 个去比 1000 个的话,比例被顶到 2 + assert_eq!(pace.ratio("up", "m"), 1.0, "没校"); + + // Anthropic:正文、推理、正文,上游的输出数里推理和正文没分开:不校 + let pace = std::sync::Arc::new(crate::pace::Pace::new()); + let mut e = streaming(ir::Dialect::Anthropic, &pace); + let delta = |i: u64, kind: &str, field: &str, t: &str| { + format!( + "event: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":{i},\"delta\":{{\"type\":\"{kind}\",\"{field}\":\"{t}\"}}}}\n\n" + ) + }; + let s = [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n".to_string(), + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n".to_string(), + delta(0, "text_delta", "text", &answer), + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n".to_string(), + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n".to_string(), + delta(1, "thinking_delta", "thinking", TEXT), + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n".to_string(), + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n".to_string(), + delta(2, "text_delta", "text", &answer), + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\n".to_string(), + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1000}}\n\n".to_string(), + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n".to_string(), + ] + .concat(); + e.feed(s.as_bytes()); + e.finished(200); + assert_eq!(pace.ratio("up", "m"), 1.0, "没校"); + } +} diff --git a/crates/tw-gateway/src/server/pipeline/relay.rs b/crates/tw-gateway/src/server/pipeline/relay.rs index 60b2b5a0..a05912cb 100644 --- a/crates/tw-gateway/src/server/pipeline/relay.rs +++ b/crates/tw-gateway/src/server/pipeline/relay.rs @@ -209,8 +209,13 @@ pub(super) fn respond( let _passing = passing; // 结局也一样:流被丢掉的时候,它替流报「客户端取消」。 let mut ending = ending; - let mut chunks = std::pin::pin!(chunks); + // 上游的流。**装箱是为了结束读的那一刻就能丢掉**:收尾、发错误帧还要一会儿,那段时间 + // 里上游不该接着生成 + let mut chunks = Box::pin(chunks); let mut broke: Option = None; + // 因为生成太慢结束的:结束那一刻的速度。这一家记失败、结局带上速度,**等结局定了再做** + // —— 收尾时回答过滤或工具调用审查也可能把它换成别的错误(见下面的 `denied`) + let mut too_slow: Option = None; // 客户端上一次收到字节的时刻。**心跳看的是客户端那一边的静默**,不是上游的: // 上游在排队时发的 SSE 注释(DeepSeek 的 `: keep-alive`、OpenRouter 的 // `: OPENROUTER PROCESSING`)转换时被丢掉,上游不算静默,客户端却什么都没收到 @@ -230,25 +235,25 @@ pub(super) fn respond( // 下一次看生成速度的时刻(这一家没设最低生成速度时用不到) let mut pace_at = tokio::time::Instant::now() + pace_every(slow); loop { - // 每秒看一次生成速度:到点了就看(上游一直在发、计时那一支轮不上的时候也看得到) + // 每秒看一次生成速度:到点了就看(上游一直在发、计时那一支轮不上的时候也看得到)。 + // 回答的最后一帧已经交出去的不再看:上游说完了过一阵才关连接,不是慢 if let Some(w) = slow + && !relay.said_all && tokio::time::Instant::now() >= pace_at { pace_at = tokio::time::Instant::now() + w.tick; + // 同时被手动中止了的:按中止算 + if abort.thrown() { + aborted = true; + broke = Some(GatewayError::aborted().in_dialect(dialect)); + break; + } if let Some(rate) = w.judge(ending.pace_at(std::time::Instant::now())) { tracing::warn!( provider = %upstream_name, rate, min = w.min, secs = w.secs, "ended the response: the upstream generated below its minimum speed" ); - // 这是用户给这一家定的「不达标」:记一次失败(重发的请求先去别家,连着几次 - // 停用),这段对话这一轮不再留在它那儿 - crate::server::note_health( - &bus, - &health, - &upstream_name, - health.record_failure(&upstream_name), - ); - ending.too_slow(rate); + too_slow = Some(rate); broke = Some( GatewayError::slow(crate::slow::too_slow( &upstream_name, @@ -364,16 +369,31 @@ pub(super) fn respond( } } } + // 不再读了:上游的连接现在就断(正常走完的,流已经到了末尾),在这家占着的位置也空出来 + drop(chunks); + drop(_slot); let (tail, denied) = relay.finish(broke.is_some()).await; // 回答里的命中这时报:一次回答一条规则一条 relay.report_answer(); - if !tail.is_empty() { - yield Ok::(Bytes::from(tail)); - } // 扣下整份 body 和流断在半路,对结局来说是同一件事 if let Some(err) = denied { broke = Some(err.in_dialect(dialect)); } + // 结局还是生成太慢:这是用户给这一家定的「不达标」,记一次失败(重发的请求先去别家, + // 连着几次停用),这段对话这一轮不再留在它那儿,结局带着速度 + if let Some(rate) = too_slow + && broke + .as_ref() + .is_some_and(|e| e.source == crate::error::Source::Slow) + { + crate::server::note_health( + &bus, + &health, + &upstream_name, + health.record_failure(&upstream_name), + ); + ending.too_slow(rate); + } // 上游在流里报的错是限流:回答照样以错误收尾(客户端已经收到了东西,换不了),这一家暂停 if let Some((code, body)) = ending .upstream_fault() @@ -401,13 +421,19 @@ pub(super) fn respond( ); } match broke { - None => ending.finished(status.as_u16()), + None => { + if !tail.is_empty() { + yield Ok::(Bytes::from(tail)); + } + ending.finished(status.as_u16()); + } Some(err) => { // 少了这个事件,UI 上那一行会永远停在「进行中」—— // 而「一直转圈」比「明确失败」更让人怀疑是我们卡住了。 // // **先报再发错误帧**:客户端恰好在最后这一帧上走掉的话, - // 结局已经报过了,不会再被记成一次取消。 + // 结局已经报过了,不会再被记成一次取消。扣着的尾巴也一样在报了之后才发: + // 记录、这一家的成败和客户端看到的是同一个结局 // // `source` 用这个错误自己的:上游断了是 `upstream`,被 // 防火墙切断是 `denied` —— 后者不是上游坏了,是策略拦的。 @@ -419,8 +445,12 @@ pub(super) fn respond( why.text = format!("the response stream broke: {}", why.text); } ending.failed(err.source.into(), why); - if let Some(frame) = relay.error_tail(&err) { - yield Ok(Bytes::from(relay.plugins_tail(&frame))); + let frame = relay.error_tail(&err).map(|f| relay.plugins_tail(&f)); + if !tail.is_empty() { + yield Ok::(Bytes::from(tail)); + } + if let Some(frame) = frame { + yield Ok(Bytes::from(frame)); } } } diff --git a/crates/tw-gateway/src/slow.rs b/crates/tw-gateway/src/slow.rs index d954d125..692b3f3f 100644 --- a/crates/tw-gateway/src/slow.rs +++ b/crates/tw-gateway/src/slow.rs @@ -10,21 +10,31 @@ //! - **token 和实时速度同一个估法**(`/live`、`RunningView::tokens_per_sec`,见 //! [`crate::ending`]):正文、推理、工具参数按字数估,乘上这家这个模型校准过的比例 //! ([`crate::pace`])。界面上看到的数就是判断的依据。 -//! - **长静默不算**:连续 [`SILENCE_SECS`] 秒以上没有内容的那一段,时长和其中的 token(0 个) -//! 都从窗口里扣掉 —— 那是无响应超时的事,回答中途想一会儿、等工具参数这类正常的停顿不该 -//! 被当成生成太慢。扣完之后剩下的不到窗口的一半,这一秒不下结论:几秒的数据说明不了什么。 +//! - **长静默不算**:连续 [`SILENCE_SECS`] 秒以上(判定窗口不到 20 秒的,窗口的四分之一以上) +//! 没有内容的那一段,时长和其中的 token(0 个)都从窗口里扣掉 —— 那是无响应超时的事,回答 +//! 中途想一会儿、等工具参数这类正常的停顿不该被当成生成太慢。扣完之后剩下的不到窗口的一半, +//! 这一秒不下结论:几秒的数据说明不了什么。 +//! - **停着的时候不判断**:上一段内容到了一秒多还没有下一段,就等它接上了再说。工具参数、整个 +//! 到的 Gemini 函数调用常常是停一下再一下子到一大段,在停着的那一秒下结论,量到的只是停顿。 +//! - **摘要式的推理不算**:Anthropic、Gemini、Bedrock 的推理和 Responses 的推理摘要是隔几秒 +//! 一段段到的,模型其实一直在快速地想(见 `crate::ending`)。推理进行着的那段时间不判断, +//! 之后从窗口里扣掉,和长静默一样。原样流出来的推理(Chat 的 `reasoning_content`、Responses +//! 的 `reasoning_text`)照常算。 //! - **第一个 token 之后不到一个窗口不判断**:开头几秒的波动不算数。第一个 token 之前的 //! 等待归无响应超时管。 +//! - **回答说完了就不再判断**(上游报了停止原因):有的上游说完之后过一阵才关连接,那段时间 +//! 不该让一个完整的回答以错误收尾。 //! //! 只管流式的回答(SSE、Bedrock 的 eventstream):整包的回答在结束之前没有可测的速度, -//! WebSocket 上的一轮也不管。 +//! WebSocket 上的一轮也不管,不带 `alt=sse` 的 Gemini 流(一个 JSON 数组)也不管。 use std::collections::VecDeque; use std::time::{Duration, Instant}; use tw_types::msg; -/// 连续多少秒没有内容算长静默(从窗口里扣掉) +/// 连续多少秒没有内容算长静默(从窗口里扣掉)。判定窗口短的按窗口的四分之一算(见 +/// [`Gauge::new`]) pub const SILENCE_SECS: u32 = 5; /// 一条流最近一段时间里生成了多少:每来一段内容记一笔,判断时看最近一个窗口。 @@ -45,8 +55,12 @@ pub struct Gauge { last: Option, /// (时刻,这一笔估出来的 token),按时间排,只留窗口里的 samples: VecDeque<(Instant, u64)>, - /// 长静默:(开始,结束),按时间排,只留和窗口有交集的 + /// 扣掉的时段:长静默和摘要式推理进行着的那段。(开始,结束),按时间排,只留和窗口有交集的 gaps: VecDeque<(Instant, Instant)>, + /// 摘要式的推理从这一刻开始,还在进行(见 [`Gauge::pause`]) + paused: Option, + /// 回答说完了(见 [`Gauge::end`]) + ended: bool, } impl Gauge { @@ -54,17 +68,45 @@ impl Gauge { Self { tick, window: tick.saturating_mul(window_secs), - silence: tick.saturating_mul(SILENCE_SECS), + // 窗口短的,五秒的停顿就是窗口的一大块:按窗口的四分之一算 + silence: tick + .saturating_mul(SILENCE_SECS) + .min(tick.saturating_mul(window_secs) / 4), first: None, last: None, samples: VecDeque::new(), gaps: VecDeque::new(), + paused: None, + ended: false, } } - /// 在 `at` 来了一段内容,估出 `tokens` 个(可以是 0:一个字估不出一个 token) + /// 摘要式的推理在 `at` 开始了:进行着的时候不判断,之后这段时间从窗口里扣掉 + pub fn pause(&mut self, at: Instant) { + self.first.get_or_insert(at); + self.paused.get_or_insert(at); + } + + /// 推理在 `at` 结束了(回答接着出来了) + pub fn resume(&mut self, at: Instant) { + if let Some(from) = self.paused.take() { + self.gaps.push_back((from, at)); + self.last = Some(self.last.map_or(at, |l| l.max(at))); + } + } + + /// 回答说完了:之后不再判断 + pub fn end(&mut self) { + self.ended = true; + } + + /// 在 `at` 来了一段内容,估出 `tokens` 个(可以是 0:一个字估不出一个 token)。摘要式的 + /// 推理进行着的时候来的不算 pub fn content(&mut self, at: Instant, tokens: u64) { self.first.get_or_insert(at); + if self.paused.is_some() { + return; + } if let Some(last) = self.last && at.saturating_duration_since(last) >= self.silence { @@ -80,9 +122,16 @@ impl Gauge { } /// 此刻最近一个窗口的平均速度,每「秒」多少 token。说不出的时候是 `None`:还没开口、 - /// 开口不到一个窗口、扣掉长静默之后剩下的不到窗口的一半 + /// 开口不到一个窗口、停着(上一段内容是一秒多以前的)、摘要式的推理进行着、回答说完了、 + /// 扣掉长静默和推理之后剩下的不到窗口的一半 pub fn rate(&mut self, now: Instant) -> Option { let first = self.first?; + if self.ended + || self.paused.is_some() + || now.saturating_duration_since(self.last?) > self.tick + { + return None; + } let start = now.checked_sub(self.window)?; if start < first { return None; @@ -96,13 +145,8 @@ impl Gauge { let tokens: u64 = self.samples.iter().map(|(_, n)| n).sum(); let overlap = |(from, to): (Instant, Instant)| to.min(now).saturating_duration_since(from.max(start)); - let mut quiet: Duration = self.gaps.iter().map(|g| overlap(*g)).sum(); - // 到此刻为止已经静了这么久:也是一段长静默,还没结束 - if let Some(last) = self.last - && now.saturating_duration_since(last) >= self.silence - { - quiet += overlap((last, now)); - } + // 停着的时候不判断(见上),到此刻为止的那一小段不会是长静默 + let quiet: Duration = self.gaps.iter().map(|g| overlap(*g)).sum(); let spoken = self.window.saturating_sub(quiet); if spoken < self.window / 2 || spoken.is_zero() { return None; @@ -202,11 +246,58 @@ mod tests { steady(&mut g, t, 30.0, 40.0, 0.25, 5); let r = g.rate(at(t, 40.0)).unwrap(); assert!((r - 20.0).abs() < 1.1, "{r}"); - // 静着的时候:扣掉之后不到窗口的一半就不说 + // 接上之后,扣掉之后不到窗口的一半就不说 + let mut g = Gauge::new(20, S); + steady(&mut g, t, 0.0, 20.0, 0.25, 5); + steady(&mut g, t, 31.0, 32.0, 0.25, 5); + assert_eq!(g.rate(at(t, 32.0)), None, "静了 11 秒,只剩 9 秒"); + } + + /// 停着的时候不判断:等内容接上了再说。窗口短的,停一会儿就算长静默 + #[test] + fn a_pause_is_not_judged_until_the_content_resumes() { + let t = Instant::now(); + let mut g = Gauge::new(5, S); + steady(&mut g, t, 0.0, 10.0, 0.25, 5); + assert!(g.rate(at(t, 10.0)).is_some()); + // 9.75 秒之后停了 2.5 秒:停着的时候一直不说 + for s in [10.8, 11.5, 12.0] { + assert_eq!(g.rate(at(t, s)), None, "{s}"); + } + // 一下子补上一大段(工具参数那样):那 2.5 秒是长静默(窗口 5 秒的门限是 1.25 秒),扣掉 + g.content(at(t, 12.25), 50); + steady(&mut g, t, 12.5, 15.0, 0.25, 5); + let r = g.rate(at(t, 15.0)).unwrap(); + assert!(r > 20.0, "{r}"); + } + + /// 摘要式的推理进行着的时候不判断,那段时间和其中的字都不算 + #[test] + fn summarised_reasoning_is_left_out() { + let t = Instant::now(); let mut g = Gauge::new(20, S); steady(&mut g, t, 0.0, 20.0, 0.25, 5); - assert!(g.rate(at(t, 25.0)).is_some(), "静了 5 秒,剩 15 秒"); - assert_eq!(g.rate(at(t, 31.0)), None, "静了 11 秒,只剩 9 秒"); + g.pause(at(t, 20.0)); + // 隔四秒一段摘要 + for s in [20.0, 24.0, 28.0, 32.0] { + g.content(at(t, s), 40); + assert_eq!(g.rate(at(t, s + 0.1)), None, "推理进行着:{s}"); + } + g.resume(at(t, 34.0)); + steady(&mut g, t, 34.0, 50.0, 0.25, 5); + // 窗口 30 到 50:扣掉 30 到 34,剩 16 秒,每秒 20 个 + let r = g.rate(at(t, 50.0)).unwrap(); + assert!((r - 20.0).abs() < 1.5, "{r}"); + } + + #[test] + fn nothing_is_judged_once_the_answer_is_over() { + let t = Instant::now(); + let mut g = Gauge::new(5, S); + steady(&mut g, t, 0.0, 10.0, 1.0, 1); + assert!(g.rate(at(t, 9.5)).is_some()); + g.end(); + assert_eq!(g.rate(at(t, 9.5)), None); } /// 短于门限的停顿照算:每 4 秒来一段,算的是平均下来的慢 @@ -215,7 +306,7 @@ mod tests { let t = Instant::now(); let mut g = Gauge::new(20, S); steady(&mut g, t, 0.0, 40.0, 4.0, 8); - let r = g.rate(at(t, 40.0)).unwrap(); + let r = g.rate(at(t, 36.5)).unwrap(); assert!((r - 2.0).abs() < 0.5, "{r}"); } diff --git a/crates/tw-gateway/tests/min_speed.rs b/crates/tw-gateway/tests/min_speed.rs index 9f45ca1d..44261ddb 100644 --- a/crates/tw-gateway/tests/min_speed.rs +++ b/crates/tw-gateway/tests/min_speed.rs @@ -32,6 +32,9 @@ const TEXT: &str = const MESSAGE_START: &str = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"usage\":{\"input_tokens\":1200,\"output_tokens\":1}}}\n\n"; const BLOCK_START: &str = "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"; +const THINKING_START: &str = "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n"; +const THINKING_STOP: &str = "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ + event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"; const PING: &str = "event: ping\ndata: {\"type\":\"ping\"}\n\n"; const END: &str = "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2000}}\n\n\ @@ -87,6 +90,31 @@ impl Script { self.words(200, ms) } + /// 每 `every` 毫秒一段推理,持续 `ms` 毫秒(推理块 0,之后的正文块是 1) + fn thinking(mut self, every: u64, ms: u64) -> Self { + self.steps + .push((0, Bytes::from_static(THINKING_START.as_bytes()))); + for _ in 0..(ms / every).max(1) { + self.steps.push(( + every, + Bytes::from(format!( + "event: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"thinking_delta\",\"thinking\":\"{TEXT}\"}}}}\n\n" + )), + )); + } + self.steps + .push((0, Bytes::from_static(THINKING_STOP.as_bytes()))); + self + } + + /// 一下子来 `n` 段 + fn burst(mut self, n: usize) -> Self { + for _ in 0..n { + self.steps.push((0, word())); + } + self + } + fn end(mut self) -> Self { self.steps.push((0, Bytes::from_static(END.as_bytes()))); self @@ -151,6 +179,16 @@ async fn upstream(script: Script) -> Upstream { Upstream { addr, dropped } } +/// 判定窗口是 `window` 「秒」的上游 +fn provider_over(up: &Upstream, min: u32, window: u32) -> Provider { + let mut p = provider(up, Some(min)); + p.min_speed = Some(MinSpeed { + tokens_per_sec: min, + window_secs: window, + }); + p +} + fn provider(up: &Upstream, min_speed: Option) -> Provider { Provider { name: "local".into(), @@ -352,8 +390,80 @@ async fn a_whole_answer_is_not_judged() { finished(&ending(&mut rx).await); } +/// 摘要式的推理(Anthropic 的 thinking)隔一阵来一段,模型其实一直在想:推理进行着的时候不判断, +/// 之后那段时间也不算 +#[tokio::test] +async fn summarised_thinking_is_not_judged() { + let up = upstream( + Script { + steps: vec![(0, Bytes::from_static(MESSAGE_START.as_bytes()))], + ..Default::default() + } + .thinking(200, 2_000) + .fast(1_200) + .end(), + ) + .await; + let (gw, mut rx, _) = gateway(provider(&up, Some(MIN))).await; + let (status, body) = post(gw, "/v1/messages", &messages(true)).await; + assert_eq!(status, 200); + assert!(!body.contains("event: error"), "{body}"); + finished(&ending(&mut rx).await); +} + +/// 窗口短的,停一下再一下子补上一大段(工具参数那样):停着的时候不判断,补上之后那段停顿不算 +#[tokio::test] +async fn a_pause_before_a_burst_is_not_judged() { + let up = upstream( + Script::opened() + .words(25, 600) + .quiet(200) + .burst(8) + .words(25, 600) + .end(), + ) + .await; + let (gw, mut rx, _) = gateway(provider_over(&up, MIN, 5)).await; + let (status, body) = post(gw, "/v1/messages", &messages(true)).await; + assert_eq!(status, 200); + assert!(!body.contains("event: error"), "{body}"); + finished(&ending(&mut rx).await); +} + // ───────────────────────────────────────────── 中断 +/// 原样流出来的推理(Chat 的 `reasoning_content`)照常判断:慢了就结束 +#[tokio::test] +async fn raw_reasoning_that_is_slow_is_ended() { + let chunk = |field: &str| { + Bytes::from(format!( + "data: {{\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{{\"index\":0,\"delta\":{{\"{field}\":\"{TEXT}\"}}}}]}}\n\n" + )) + }; + let mut steps: Vec<(u64, Bytes)> = (0..30).map(|_| (10, chunk("reasoning_content"))).collect(); + steps.extend((0..25).map(|_| (200, chunk("reasoning_content")))); + let up = upstream(Script { + steps, + hang: true, + ..Default::default() + }) + .await; + let p = Provider { + protocol: Some(Protocol::OpenaiChat), + ..provider(&up, Some(MIN)) + }; + let (gw, mut rx, _) = gateway(p).await; + let (status, out) = post( + gw, + "/v1/chat/completions", + &json!({"model": "m", "stream": true, "messages": [{"role": "user", "content": "hi"}]}), + ) + .await; + assert_eq!(status, 200, "{out}"); + assert!(out.contains("Upstream `local` generated"), "{out}"); + too_slow(&ending(&mut rx).await); +} + /// 2. 中途掉速并持续了一个窗口:以 503 的错误帧结束,断开上游,这一家记一次失败; /// 失败带着结束那一刻的速度,输出按估数记 #[tokio::test] diff --git a/crates/tw-store/src/db.rs b/crates/tw-store/src/db.rs index cc8eb1da..b72d2191 100644 --- a/crates/tw-store/src/db.rs +++ b/crates/tw-store/src/db.rs @@ -1433,7 +1433,7 @@ impl Db { let mut st = self.conn.prepare(&format!( "SELECT {by}, tokens_per_sec FROM requests WHERE at_ms >= ?1 AND at_ms < ?2 AND local = 0 AND tokens_per_sec IS NOT NULL - AND {by} <> '' + AND error IS NULL AND {by} <> '' ORDER BY {by}, tokens_per_sec" ))?; let rows = st.query_map(params![since_ms, until_ms], |r| { diff --git a/crates/tw-store/src/health.rs b/crates/tw-store/src/health.rs index 1d42f5f1..37f31c13 100644 --- a/crates/tw-store/src/health.rs +++ b/crates/tw-store/src/health.rs @@ -63,7 +63,8 @@ impl Db { cancelled OR COALESCE(error_code = ?3, 0), error IS NOT NULL AND NOT COALESCE(error_code = ?3, 0), input_tokens, COALESCE(cache_read_tokens, 0), - COALESCE(cache_write_tokens, 0), input_estimate, ttft_ms, tokens_per_sec + COALESCE(cache_write_tokens, 0), input_estimate, ttft_ms, + CASE WHEN error IS NULL THEN tokens_per_sec END FROM requests WHERE at_ms >= ?1 AND at_ms < ?2 AND local = 0 ORDER BY at_ms, id", @@ -962,8 +963,8 @@ mod tests { } /// 延迟和速度和按上游分的那两个查询(`latency_by_provider`、`token_rate_by_provider`) - /// **口径一样**:同一批行,同样的中位数和样本数。取消的、失败的照样进样本,和那两页 - /// 一样 + /// **口径一样**:同一批行,同样的中位数和样本数。取消的、失败的照样进延迟的样本,和那两页 + /// 一样;速度只算没失败的(失败的只有生成太慢被结束的才有速度,量的是判定窗口) #[test] fn latency_and_speed_match_the_per_upstream_queries() { let db = Db::in_memory().unwrap(); @@ -979,6 +980,16 @@ mod tests { r.cancelled = cancelled; db.insert(&r).unwrap(); } + // 因为生成太慢被结束的:带着结束那一刻判定窗口里的速度,是另一个量,不进样本 + let mut slow = on(5, T0 + 5, "中转", "claude-sonnet-4-5"); + slow.ttft_ms = None; + slow.tokens_per_sec = Some(3); + slow.error = Some(tw_api::Msg { + code: "gw.upstream.too_slow".into(), + args: Default::default(), + text: "too slow".into(), + }); + db.insert(&slow).unwrap(); let mut quiet = on(9, T0 + 9, "官方", "claude-sonnet-4-5"); quiet.ttft_ms = None; quiet.tokens_per_sec = None; diff --git a/docs/config.md b/docs/config.md index 4eb405f2..474819c0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -544,10 +544,18 @@ clients, `response.failed` with `server_error` for Responses clients, The speed is counted the way the live speed in the app is: text, reasoning and tool arguments, estimated and calibrated against what the upstream reports. -Pauses of 5 seconds or more partway through are left out, since the idle -timeout deals with them; when what is left of the window is under half of it, -nothing is judged that second. The wait before the first token is not judged -either, nor are answers that are not streamed. +Pauses of 5 seconds or more partway through (a quarter of the window when it +is under 20 seconds) are left out, since the idle timeout deals with them; when +what is left of the window is under half of it, nothing is judged that second. +Nothing is judged while the stream is paused (no content for more than a +second): tool arguments often arrive in one burst after a pause. Reasoning that +arrives as a summary, a few sentences every few seconds while the model thinks +fast (Anthropic, Gemini and Bedrock reasoning, the Responses reasoning summary), +is not judged while it lasts and its time is left out afterwards; reasoning +streamed as it is generated (`reasoning_content` in OpenAI Chat, the Responses +`reasoning_text`) is judged like text. The wait before the first token is not +judged, nor is anything after the answer has ended, nor are answers that are +not streamed, including Gemini streams requested without `alt=sse`. The request is recorded as failed with the speed it had, the part already generated is billed, and the upstream counts one failure toward diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 1c32d0e6..718f4987 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -365,8 +365,12 @@ ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到 `response.failed`,Gemini 格式收到 `UNAVAILABLE`),并断开和上游的连接。 速度和应用里显示的实时速度同一个算法:正文、推理和工具参数,按估算并用上游报的实数校准。 -中途 5 秒以上的停顿不算在内,由无响应超时处理;扣掉之后剩下的不到窗口的一半时,这一秒不判断。 -第一个 token 之前的等待不判断,不是流式的回答也不判断。 +中途 5 秒以上(判定时长不到 20 秒时为它的四分之一以上)的停顿不算在内,由无响应超时处理;扣掉 +之后剩下的不到窗口的一半时,这一秒不判断。停着的时候(一秒多没有新内容)不判断:工具参数常常 +停一下再一下子到。以摘要形式隔几秒到一段的推理(Anthropic、Gemini、Bedrock 的推理,Responses +的推理摘要)进行着的时候不判断,这段时间之后也不算;原样流出来的推理(OpenAI Chat 的 +`reasoning_content`、Responses 的 `reasoning_text`)和正文一样判断。第一个 token 之前的等待、 +回答说完之后、不是流式的回答都不判断,不带 `alt=sse` 的 Gemini 流也不判断。 请求记为失败并记下当时的速度,已经生成的部分照常计费,这家记一次失败,计入 `failover.failures_to_pause`;有别的候选时,重试的请求先去别家。 From 4566c7f4fca35a4c957bc9337a054f0ae18de89f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:51:12 +0800 Subject: [PATCH 2/2] Release the upstream stream early only when the answer was cut Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/server/pipeline/relay.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tw-gateway/src/server/pipeline/relay.rs b/crates/tw-gateway/src/server/pipeline/relay.rs index a05912cb..8b3044c4 100644 --- a/crates/tw-gateway/src/server/pipeline/relay.rs +++ b/crates/tw-gateway/src/server/pipeline/relay.rs @@ -369,9 +369,15 @@ pub(super) fn respond( } } } - // 不再读了:上游的连接现在就断(正常走完的,流已经到了末尾),在这家占着的位置也空出来 - drop(chunks); - drop(_slot); + // 断在半路的(中止、超时、生成太慢、切断):上游的连接现在就断,在这家占着的位置也空出来 + // —— 收尾、发错误帧还要一会儿,那段时间里上游不该接着生成。正常走完的照旧跟着响应体走 + let (_chunks, _slot) = if broke.is_some() { + drop(chunks); + drop(_slot); + (None, None) + } else { + (Some(chunks), Some(_slot)) + }; let (tail, denied) = relay.finish(broke.is_some()).await; // 回答里的命中这时报:一次回答一条规则一条 relay.report_answer();