修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断 - SSE 超限截头改对齐事件边界保尾,流不再永久破损 - content_filter 纳入终态判定,无 DONE 流也收敛 - sanitize 占位补头前缀短路只吞 orphan 的盲区消除 - intent 关键词加词边界,history 不再误判 - 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
This commit is contained in:
@@ -48,6 +48,8 @@ impl Default for TokenEstimator {
|
||||
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
||||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
||||
// 会被 0.35 稀释成 560,保底名存实亡。
|
||||
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
|
||||
|
||||
impl TokenEstimator {
|
||||
@@ -59,6 +61,10 @@ impl TokenEstimator {
|
||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||
let mut char_count = msg.content.chars().count();
|
||||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
||||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
||||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
||||
let mut url_image_floor_tokens: u32 = 0;
|
||||
if let Some(parts) = &msg.parts {
|
||||
for p in parts {
|
||||
match p {
|
||||
@@ -66,19 +72,20 @@ impl TokenEstimator {
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} else if url.is_some() {
|
||||
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
|
||||
char_count += URL_IMAGE_TOKEN_FLOOR;
|
||||
url_image_floor_tokens = url_image_floor_tokens
|
||||
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||||
let mut total = content_tokens + self.per_message_overhead;
|
||||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
@@ -833,8 +840,14 @@ impl WorkingContext {
|
||||
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
|
||||
}
|
||||
|
||||
// L1b: 过去 N 轮内有变更才注入
|
||||
let threshold = self.version.current_turn.saturating_sub(recent_turns);
|
||||
// L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
|
||||
// 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
|
||||
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
|
||||
let threshold = self
|
||||
.version
|
||||
.current_turn
|
||||
.saturating_sub(recent_turns)
|
||||
.saturating_add(1);
|
||||
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
|
||||
if has_active && self.version.goals_changed_at >= threshold {
|
||||
let active: Vec<&str> = self.goals.iter()
|
||||
@@ -899,6 +912,12 @@ impl WorkingContext {
|
||||
if !items.is_empty() {
|
||||
self.decisions = items;
|
||||
}
|
||||
} else {
|
||||
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
|
||||
tracing::warn!(
|
||||
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
|
||||
json_card.len()
|
||||
);
|
||||
}
|
||||
self.version.reset_all(json_card, self.version.current_turn);
|
||||
}
|
||||
@@ -1674,4 +1693,41 @@ mod tests {
|
||||
let out = ctx.build_injection(3);
|
||||
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_injection_window_is_exactly_n_turns() {
|
||||
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
|
||||
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
|
||||
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
|
||||
ctx.version.current_turn = 3;
|
||||
assert!(
|
||||
ctx.build_injection(3).contains("窗口边界目标"),
|
||||
"第 N 轮前的变更恰在窗内,应注入"
|
||||
);
|
||||
ctx.version.current_turn = 4;
|
||||
assert!(
|
||||
ctx.build_injection(3).is_empty(),
|
||||
"第 N+1 轮前的变更应出窗,不注入"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_image_token_floor_survives_ratio_discount() {
|
||||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
||||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||
let est = TokenEstimator::default();
|
||||
let msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||||
);
|
||||
let tokens = est.estimate_message(&msg);
|
||||
assert!(
|
||||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
||||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||||
tokens,
|
||||
URL_IMAGE_TOKEN_FLOOR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user