package model // 表示 token 预算把生成切断的提供商停止/结束原因: // Anthropic 上报 stop_reason "max_tokens",OpenAI 兼容提供商 // 上报 finish_reason "length"。两者都被原样归一化进 Response.StopReason, // 因此链路追踪保留提供商自己的词汇。 const ( stopReasonMaxTokens = "max_tokens" stopReasonLength = "length" ) // 截断恢复的边界。最多允许一次带翻倍预算的重试; // 上限防止病态预算无界增长。hint 是连重试 // 也没产出可见文本时返回的非空占位, // 因此被截断的回合绝不会以静默空回复的形式浮出。 // 它绝不携带思考:推理不是答案。 const ( truncationRetryCap = 32768 truncationEmptyHint = "(思考过长,未能生成回复)" ) // effectiveMaxTokens 解出一次尝试实际发送的预算:显式的 // Request.MaxTokens,否则用适配器默认值。当协议没有适配器 // 默认值时 def 可以为零,此时适用提供商默认值。 func effectiveMaxTokens(n, def int) int { if n > 0 { return n } return def } // retryMaxTokens 为一次性的截断重试翻倍实际生效预算, // 上限为 truncationRetryCap。当没有什么可翻倍时(n 与 def 都是零, // 即适用提供商默认值),它原样返回实际生效预算,从而让 // 调用方跳过那次会发出完全相同请求的重试。 func retryMaxTokens(n, def int) int { eff := effectiveMaxTokens(n, def) if eff <= 0 { return 0 } if bumped := eff * 2; bumped < truncationRetryCap { return bumped } return truncationRetryCap } // isTruncatedStopReason 报告某个停止原因是否意味着 token 预算 // 切断了生成。 func isTruncatedStopReason(reason string) bool { return reason == stopReasonMaxTokens || reason == stopReasonLength } // isEmptyBody 报告响应是否既无可见文本也无工具 // 调用。仅工具的一轮并不是空:它的空正文是正常的、属于 // 工具循环。 func isEmptyBody(r Response) bool { return r.Content == "" && len(r.ToolCalls) == 0 } // isTruncatedEmpty 报告某次尝试是否在产出任何可见输出之前 // 就被 token 预算切断(无文本、无工具调用)。只有这种形状 // 才触发截断重试/兜底;正常的 tool_use 回合,其正文 // 合法地为空,不会触发。 func isTruncatedEmpty(r Response) bool { return isEmptyBody(r) && isTruncatedStopReason(r.StopReason) } // withEmptyBodyHint 在响应既无文本也无工具调用时填入一段 // 非空占位正文,因此被截断的回合绝不会以静默 // 空回复的形式浮出。已携带文本或工具调用的响应 // 原样返回。 func withEmptyBodyHint(r Response) Response { if !isEmptyBody(r) { return r } r.Content = truncationEmptyHint r.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: r.Content}}} return r } // callWithTruncationRetry 执行一次非流式尝试,当第一次尝试 // 在无可见输出下被切断时,用翻倍 token 预算重试一次, // 随后在重试仍无产出时返回非空占位。attempt 接收要发送 // 的预算。第一次尝试的上游错误会被原样返回(不重试); // 重试失败则退回占位, // 而不是浮出一个空回复。 func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Response, error)) (Response, error) { out, err := attempt(maxTokens) if err != nil { return Response{}, err } if !isTruncatedEmpty(out) { return out, nil } if bumped := retryMaxTokens(maxTokens, def); bumped > effectiveMaxTokens(maxTokens, def) { if second, retryErr := attempt(bumped); retryErr == nil { out = second } } return withEmptyBodyHint(out), nil } // callStreamWithTruncationRetry 是 callWithTruncationRetry 的 // 流式对位实现。attempt 接收该次尝试的预算与事件回调。 // 重试会丢弃思考事件(第一次尝试已经 // 流过它的推理,再流一次会产生重复块), // 同时实时转发文本/工具事件;当连重试也没有可见 // 输出时,会以文本片段发出非空占位并把它作为 // 响应正文返回,因此流绝不会以静默截断收尾。 func callStreamWithTruncationRetry( maxTokens, def int, attempt func(limit int, onEvent func(StreamEvent)) (Response, error), onEvent func(StreamEvent), ) (Response, error) { out, err := attempt(maxTokens, onEvent) if err != nil { return Response{}, err } if !isTruncatedEmpty(out) { return out, nil } if bumped := retryMaxTokens(maxTokens, def); bumped > effectiveMaxTokens(maxTokens, def) { if second, retryErr := attempt(bumped, dropThinking(onEvent)); retryErr == nil { out = second } } if isEmptyBody(out) { out = withEmptyBodyHint(out) emitTextSegment(onEvent, out.Content) } return out, nil } // dropThinking 包裹 onEvent,使重试尝试不会再次发出思考 // 片段。文本与 tool_call 事件原样通过。 func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) { if onEvent == nil { return nil } return func(ev StreamEvent) { switch ev.Kind { case StreamKindThinkingStart, StreamKindThinkingDelta, StreamKindThinkingEnd: return default: onEvent(ev) } } } // emitTextSegment 发出一个单增量的文本片段,使合成的占位 // 能到达只渲染 message.* 事件的流式消费者。 func emitTextSegment(onEvent func(StreamEvent), text string) { if onEvent == nil || text == "" { return } onEvent(StreamEvent{Kind: StreamKindTextStart}) onEvent(StreamEvent{Kind: StreamKindTextDelta, Delta: text}) onEvent(StreamEvent{Kind: StreamKindTextEnd}) }