fix(model): 解析 stop_reason + max_tokens 截断重试/非空兜底 — 不再静默空回复
- model.go: Response.StopReason; anthropic wire加stop_reason; openai finish_reason映射(两协议统一,原值透传)
- truncation.go(新,159行): effectiveMaxTokens/retryMaxTokens(×2封顶32768)/isTruncatedEmpty(空正文+无tool_use+stop_reason∈{max_tokens,length})/withEmptyBodyHint/callWithTruncationRetry/callStreamWithTruncationRetry/dropThinking/emitTextSegment
- anthropic.go Chat/ChatStream 走重试+兜底; anthropic_encode.go(新)拆编码(anthropic.go 529→357合规); anthropic_stream message_delta落StopReason
- agent/loop: 把模型层StopReason带到llm.call span的model_stop_reason属性(修掉trace硬编码stop的误导); loop.Result.StopReason语义未动
- 触发仅当截断空正文; tool_use空正文永不触发(native_toolcall_test仍过); 至多1次重试; 兜底回'(思考过长,未能生成回复)'绝不用thinking
- 新增7测试(非流式/流式重试+兜底+tool_use不触发+封顶+openai映射), build/vet/test绿+3平台交叉编译
This commit is contained in:
1 parent
18b2040c95
commit
c7b2cd8ae0
10 files changed
+792
-200
No files matched your search
@@ -0,0 +1,159 @@
|
||||
package model
|
||||
|
||||
// Provider stop/finish reasons that mean the token budget cut the generation
|
||||
// off: Anthropic reports stop_reason "max_tokens", OpenAI-compatible providers
|
||||
// report finish_reason "length". Both are normalized into Response.StopReason
|
||||
// verbatim so a trace keeps the provider's own vocabulary.
|
||||
const (
|
||||
stopReasonMaxTokens = "max_tokens"
|
||||
stopReasonLength = "length"
|
||||
)
|
||||
|
||||
// Truncation-recovery bounds. At most one retry with a doubled budget is
|
||||
// allowed; the cap keeps a pathological budget from growing without bound. The
|
||||
// hint is the non-empty placeholder returned when even the retry produced no
|
||||
// visible text, so a truncated turn never surfaces as a silent empty reply.
|
||||
// It never carries thinking: reasoning is not the answer.
|
||||
const (
|
||||
truncationRetryCap = 32768
|
||||
truncationEmptyHint = "(思考过长,未能生成回复)"
|
||||
)
|
||||
|
||||
// effectiveMaxTokens resolves the budget an attempt actually sends: the explicit
|
||||
// Request.MaxTokens, else the adapter default. def may be zero when the protocol
|
||||
// has no adapter default, in which case the provider default applies.
|
||||
func effectiveMaxTokens(n, def int) int {
|
||||
if n > 0 {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// retryMaxTokens doubles the effective budget for the one-shot truncation retry,
|
||||
// capped at truncationRetryCap. It returns the effective budget unchanged when
|
||||
// there is nothing to double (both n and def are zero, i.e. the provider default
|
||||
// applies), letting the caller skip a retry that would send an identical request.
|
||||
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 reports whether a stop reason means the token budget cut
|
||||
// the generation off.
|
||||
func isTruncatedStopReason(reason string) bool {
|
||||
return reason == stopReasonMaxTokens || reason == stopReasonLength
|
||||
}
|
||||
|
||||
// isEmptyBody reports whether a response carries neither visible text nor tool
|
||||
// calls. A tool-only round is NOT empty: its empty body is normal and belongs to
|
||||
// the tool loop.
|
||||
func isEmptyBody(r Response) bool {
|
||||
return r.Content == "" && len(r.ToolCalls) == 0
|
||||
}
|
||||
|
||||
// isTruncatedEmpty reports whether an attempt was cut off by the token budget
|
||||
// before producing any visible output (no text, no tool call). Only this shape
|
||||
// triggers the truncation retry/fallback; a normal tool_use round, whose body is
|
||||
// legitimately empty, does not.
|
||||
func isTruncatedEmpty(r Response) bool {
|
||||
return isEmptyBody(r) && isTruncatedStopReason(r.StopReason)
|
||||
}
|
||||
|
||||
// withEmptyBodyHint fills a non-empty placeholder body when a response has
|
||||
// neither text nor tool calls, so a truncated turn never surfaces as a silent
|
||||
// empty reply. A response that already carries text or tool calls is returned
|
||||
// unchanged.
|
||||
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 runs a non-streaming attempt, retrying once with a
|
||||
// doubled token budget when the first attempt was cut off with no visible
|
||||
// output, then returning a non-empty placeholder when the retry still produced
|
||||
// nothing. attempt receives the budget to send. An upstream error on the first
|
||||
// attempt is returned unchanged (no retry); a failed retry falls back to the
|
||||
// placeholder rather than surfacing an empty reply.
|
||||
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 is the streaming counterpart of
|
||||
// callWithTruncationRetry. attempt receives the budget and the event callback
|
||||
// for that attempt. The retry drops thinking events (the first attempt already
|
||||
// streamed its reasoning, so re-streaming it would duplicate the block) while
|
||||
// forwarding text/tool events live; when even the retry yields no visible
|
||||
// output, a non-empty placeholder is emitted as a text segment and returned as
|
||||
// the response body, so the stream never ends on a silent truncation.
|
||||
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 wraps onEvent so a retry attempt does not re-emit thinking
|
||||
// segments. Text and tool_call events pass through unchanged.
|
||||
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 emits a single-delta text segment so a synthesized placeholder
|
||||
// reaches streaming consumers that render only message.* events.
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user