fix: stabilize eino summarization for deepseek

This commit is contained in:
temp
2026-08-25 14:54:02 +08:00
parent 21c6ad9bdf
commit e4441f91ad
7 changed files with 119 additions and 25 deletions
@@ -10,7 +10,6 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
@@ -109,9 +108,7 @@ func newEinoAgenticSummarizationMiddleware(
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "agentic", &appCfg.OpenAI, logger)
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
+28 -18
View File
@@ -164,24 +164,7 @@ func newEinoSummarizationMiddleware(
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info("eino summarization generate request",
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody)
}),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "classic", &appCfg.OpenAI, logger)
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
@@ -308,6 +291,33 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// newEinoSummarizationModelOptions applies only to summarization Generate calls
// on the shared main model. Summary generation should be plain-text and cheap:
// strip provider reasoning/thinking controls so DeepSeek/OpenAI-compatible
// endpoints do not spend the reserved output budget on invisible reasoning.
func newEinoSummarizationModelOptions(outputReserve int, modelName, kind string, oa *config.OpenAIConfig, logger *zap.Logger) []model.Option {
label := "eino summarization generate request"
if strings.TrimSpace(kind) != "" && kind != "classic" {
label = "eino " + kind + " summarization generate request"
}
return []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info(label,
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody, oa)
}),
}
}
// summarizationInputBudgetOpts controls spill/truncation behavior when a round alone exceeds budget.
type summarizationInputBudgetOpts struct {
toolMaxBytes int
+22 -1
View File
@@ -1,12 +1,33 @@
package multiagent
import (
"strings"
"cyberstrike-ai/internal/config"
copenai "cyberstrike-ai/internal/openai"
)
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
// chat-completions JSON body. Applied only to summarization Generate calls via
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
func stripReasoningFromSummarizationPayload(rawBody []byte, oa *config.OpenAIConfig) ([]byte, error) {
if shouldDisableDeepSeekThinkingForSummarization(oa) {
return copenai.DisableThinkingForChatCompletionBody(rawBody)
}
return copenai.StripReasoningFromChatCompletionBody(rawBody)
}
func shouldDisableDeepSeekThinkingForSummarization(oa *config.OpenAIConfig) bool {
if oa == nil {
return false
}
profile := strings.ToLower(strings.TrimSpace(oa.Reasoning.ProfileEffective()))
switch profile {
case "deepseek", "deepseek_compat":
return true
case "", "auto":
return oa.IsDeepSeekEndpointOrModel()
default:
return false
}
}
@@ -3,11 +3,13 @@ package multiagent
import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
out, err := stripReasoningFromSummarizationPayload(in)
out, err := stripReasoningFromSummarizationPayload(in, nil)
if err != nil {
t.Fatal(err)
}
@@ -20,7 +22,7 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
}
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
out2, err := stripReasoningFromSummarizationPayload(plain)
out2, err := stripReasoningFromSummarizationPayload(plain, nil)
if err != nil {
t.Fatal(err)
}
@@ -28,3 +30,41 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
t.Fatalf("expected unchanged payload, got %s", out2)
}
}
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekThinking(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning_effort stripped, got %s", s)
}
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
t.Fatalf("expected DeepSeek thinking disabled, got %s", s)
}
}
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfile(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
},
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected OpenAI-compatible profile to strip reasoning fields, got %s", s)
}
}
@@ -52,6 +52,9 @@ func isEinoTransientRunError(err error) bool {
if msg == "" {
return false
}
if isEinoEmptySummaryContentErrorText(msg) {
return true
}
if status := httpStatusFromErrorText(msg); status > 0 {
return isRetryableHTTPStatus(status)
}
@@ -94,6 +97,11 @@ func isEinoTransientRunError(err error) bool {
return false
}
func isEinoEmptySummaryContentErrorText(msg string) bool {
return strings.Contains(msg, "summary content is empty") ||
strings.Contains(msg, "agentic summarization returned empty summary")
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case 408, 409, 425, 429:
@@ -36,6 +36,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
{"unexpected eof", errors.New("unexpected EOF"), true},
{"empty summarization output", errors.New("[NodeRunError] summary content is empty\nnode path: [node_1, ChatModel]"), true},
{"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false},
{"canceled", context.Canceled, false},