跳转至

会话摘要(Summary)

概述

随着对话持续增长,维护完整的事件历史可能会占用大量内存,并可能超出 LLM 的上下文窗口限制。会话摘要功能使用 LLM 自动将历史对话压缩为简洁的摘要,在保留重要上下文的同时显著降低内存占用和 token 消耗。

核心特性

  • 自动触发:在执行摘要检查时,根据事件数量、token 数量或时间阈值自动生成摘要
  • 增量处理:只处理自上次摘要以来的新事件,避免重复计算
  • LLM 驱动:使用任何配置的 LLM 模型生成高质量、上下文感知的摘要
  • 非破坏性:原始事件完整保留,摘要单独存储
  • 异步处理:后台异步执行,不阻塞对话流程
  • 灵活配置:支持自定义触发条件、提示词和字数限制

基础配置

步骤 1:创建摘要器

使用 LLM 模型创建摘要器并配置触发条件:

import (
    "time"

    "trpc.group/trpc-go/trpc-agent-go/session/summary"
    "trpc.group/trpc-go/trpc-agent-go/model/openai"
)

// 创建用于摘要的 LLM 模型
summaryModel := openai.New("gpt-4", openai.WithAPIKey("your-api-key"))

// 创建摘要器并配置触发条件
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithChecksAny(                     // 任一条件满足即触发
        summary.CheckEventThreshold(20),       // 自上次摘要后新增 20 个事件后触发
        summary.CheckTokenThreshold(4000),     // 自上次摘要后新增 4000 个 token 后触发
        summary.CheckTimeThreshold(5*time.Minute), // 在摘要检查时判断;比较被检查 session 的最后一个事件(在增量摘要路径里通常就是最近一个待摘要事件)
    ),
    summary.WithMaxSummaryWords(200),          // 限制摘要在 200 字以内
)

步骤 2:配置会话服务

将摘要器集成到会话服务(内存或 Redis):

import (
    "context"
    "time"
    "trpc.group/trpc-go/trpc-agent-go/session/clickhouse"
    "trpc.group/trpc-go/trpc-agent-go/session/inmemory"
    "trpc.group/trpc-go/trpc-agent-go/session/mysql"
    "trpc.group/trpc-go/trpc-agent-go/session/postgres"
    "trpc.group/trpc-go/trpc-agent-go/session/redis"
    "trpc.group/trpc-go/trpc-agent-go/session/summary"
)

// 内存存储(开发/测试)
sessionService := inmemory.NewSessionService(
    inmemory.WithSummarizer(summarizer),
    inmemory.WithAsyncSummaryNum(2),                // 2 个异步 worker
    inmemory.WithSummaryQueueSize(100),             // 队列大小 100
    inmemory.WithSummaryJobTimeout(60*time.Second), // 单个任务超时 60 秒
)

// Redis 存储(生产环境)
sessionService, err := redis.NewService(
    redis.WithRedisClientURL("redis://localhost:6379"),
    redis.WithSummarizer(summarizer),
    redis.WithAsyncSummaryNum(4),           // 4 个异步 worker
    redis.WithSummaryQueueSize(200),        // 队列大小 200
)

// PostgreSQL 存储
sessionService, err := postgres.NewService(
    postgres.WithHost("localhost"),
    postgres.WithPassword("your-password"),
    postgres.WithSummarizer(summarizer),
    postgres.WithAsyncSummaryNum(2),       // 2 个异步 worker
    postgres.WithSummaryQueueSize(100),    // 队列大小 100
)

// MySQL 存储
sessionService, err := mysql.NewService(
    mysql.WithMySQLClientDSN("user:password@tcp(localhost:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"),
    mysql.WithSummarizer(summarizer),
    mysql.WithAsyncSummaryNum(2),           // 2个异步 worker
    mysql.WithSummaryQueueSize(100),        // 队列大小 100
)

// ClickHouse 存储
sessionService, err := clickhouse.NewService(
    clickhouse.WithClickHouseDSN("clickhouse://default:password@localhost:9000/default"),
    clickhouse.WithSummarizer(summarizer),
    clickhouse.WithAsyncSummaryNum(2),
)

步骤 3:配置 Agent 和 Runner

创建 Agent 并配置摘要注入行为:

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/runner"
)

// 创建 Agent(配置摘要注入行为)
llmAgent := llmagent.New(
    "my-agent",
    llmagent.WithModel(summaryModel),
    llmagent.WithAddSessionSummary(true),   // 启用摘要注入
    llmagent.WithMaxHistoryRuns(10),        // 当AddSessionSummary=false时限制历史轮次
)

// 创建 Runner
r := runner.NewRunner(
    "my-agent",
    llmAgent,
    runner.WithSessionService(sessionService),
)

// 运行对话 - 摘要将自动管理
eventChan, err := r.Run(ctx, userID, sessionID, userMessage)

完成以上配置后,摘要功能即可自动运行。

Cache-Safe 摘要 Forking

默认情况下,摘要器会发送独立的摘要请求:可选的摘要 system prompt 加上一个 包含已提取对话文本的 user prompt。这条路径简单直接,并且仍然是默认行为。

如果长会话场景对 prompt cache 命中率比较敏感,可以显式开启 cache-safe forking:

1
2
3
4
5
6
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithContextThreshold(),
    summary.WithMaxSummaryWords(200),
    summary.WithCacheSafeForking(true),
)

开启后,如果框架当前能拿到父会话的模型请求,摘要器会克隆这个父请求,并只在 末尾追加一条用于压缩的 user message。这样可以保留父请求的 prefix,包括 system context、历史消息和工具定义,让支持 prompt cache 的模型服务复用更多 已缓存输入。如果当前没有父请求,例如异步摘要或手动调用摘要接口,摘要器会自动 回退到默认的独立摘要请求。

追加的压缩提示词和 WithPrompt(...) 是分开的,因为它不再嵌入 {conversation_text};父请求本身已经包含对话前缀。只有需要自定义这条追加的 user message 时,才需要使用 WithCacheSafeForkPrompt(...)

Cache-safe forking 控制的是“生成摘要那次请求”的构造方式。摘要已经生成以后, 下一次普通对话请求如果也希望更利于 prompt cache,建议把摘要注入为 user message,而不是合并进 system prompt:

1
2
3
4
5
6
llmAgent := llmagent.New(
    "my-agent",
    llmagent.WithModel(summaryModel),
    llmagent.WithAddSessionSummary(true),
    llmagent.WithSessionSummaryInjectionMode(llmagent.SessionSummaryInjectionUser),
)

摘要 + 渐进式披露

当摘要注入和 prompt 侧的上下文压缩一起工作时,旧细节可能不再直接出现在 模型可见的请求里。如果你希望 Agent 只在需要时再把这些细节取回来,可以启用 会话历史的渐进式披露。

import (
    "os"

    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/session/pgvector"
)

// embedder := ...(例如 OpenAI / Gemini embedder;详见嵌入器配置文档)
sessionService, err := pgvector.NewService(
    pgvector.WithDSN(os.Getenv("PGVECTOR_DSN")),
    pgvector.WithEmbedder(embedder),
    pgvector.WithSummarizer(summarizer),
)
if err != nil {
    panic(err)
}

llmAgent := llmagent.New(
    "my-agent",
    llmagent.WithModel(summaryModel),
    llmagent.WithAddSessionSummary(true),
    llmagent.WithEnableContextCompaction(true),
    llmagent.WithEnableOnDemandSession(true),
)

启用条件与行为:

  • WithEnableOnDemandSession(true) 会按后端能力暴露按需 session 工具: 后端实现 session.SearchableService 时暴露 session_search,实现 session.WindowService 时暴露 session_load。后端可以只支持其中一个, 也可以同时支持两者。
  • session/pgvector 同时支持语义发现和精确加载。普通 session 后端只要实现了 WindowService,即使没有语义 session_search,也可以暴露精确的 session_load 恢复能力。
  • current_hidden 会严格搜索当前 session 中、位于 summary:last_included_ts 之前的历史内容。summary:last_included_ts 是摘要中记录的 last_included_ts 时间戳,表示该摘要覆盖到的最后一个事件时间。
  • current_session 会搜索整个当前 session,不受 summary cutoff 限制。 当请求投影或 context compaction 把当前 session 的细节裁掉时,这个 scope 最有用。
  • other_sessions 会搜索同一 <appName, userID> 下的其他 session。
  • all_sessions 会合并 current_hiddenother_sessions

当前可召回的内容:

  • 用户消息和助手消息。
  • 历史 tool result,包括那些因为上下文压缩而没有直接出现在 prompt 里的工具输出。

当前不会索引的内容:

  • 原始 tool call 请求本身不会被索引。
  • partial event 不会被索引。

推荐使用方式:

  1. 先让模型基于当前可见 prompt、summary 和最近历史正常回答。
  2. 如果 session_search 可用且缺少旧细节,再先调用 session_search
  3. 当已经有 event_id 且需要周边原始历史或精确 tool result 时,调用 session_load;这同样适用于没有语义搜索能力的后端。
  4. 取回的内容应视为历史上下文,而不是当前轮的主动指令。

迁移提示:早期版本只有在 session_searchsession_load 同时存在时, 才认为按需 session 能力可用。现在工具面按能力分别暴露,因此 search-only 集成可以只暴露 session_search,load-only 集成可以只暴露 session_load

SessionSummarizer 接口

type SessionSummarizer interface {
    // ShouldSummarize checks if the session should be summarized.
    ShouldSummarize(sess *session.Session) bool

    // Summarize generates a summary without modifying the session events.
    Summarize(ctx context.Context, sess *session.Session) (string, error)

    // SetPrompt updates the summarizer's prompt dynamically.
    SetPrompt(prompt string)

    // SetModel updates the summarizer's model dynamically.
    SetModel(m model.Model)

    // Metadata returns metadata about the summarizer configuration.
    Metadata() map[string]any
}

上下文感知的摘要检查

已发布的 SessionSummarizer 接口保持不变。

如果摘要触发条件依赖请求上下文,可以直接使用 ContextChecker 以及带 context 的检查选项:

type asyncSummaryKey struct{}

eventThreshold := summary.CheckEventThreshold(20)

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithChecksAnyContext(
        func(ctx context.Context, sess *session.Session) bool {
            if eventThreshold(sess) {
                return true
            }
            async, _ := ctx.Value(asyncSummaryKey{}).(bool)
            return async
        },
    ),
)

框架本身不会为摘要触发方式预留 context key。如果业务需要区分不同的 摘要入口,可以在调用 session API 之前自行往 ctx 写入标记,并在 ContextChecker 中读取。

动态摘要器

当会话服务需要复用,但摘要模型、提示词或检查条件需要按请求变化时,可以 使用 NewDynamicSummarizer。这适合多租户系统、自定义模型路由等场景。 对于 MySQL 等数据库会话服务,建议保持 session service 长生命周期复用, 从而复用底层连接池,而不是为了更换摘要器按请求新建 service。

type summaryCfgKey struct{}

type SummaryCfg struct {
    ModelName string
    Prompt    string
}

func WithSummaryCfg(ctx context.Context, cfg SummaryCfg) context.Context {
    return context.WithValue(ctx, summaryCfgKey{}, cfg)
}

func SummaryCfgFromContext(ctx context.Context) (SummaryCfg, bool) {
    cfg, ok := ctx.Value(summaryCfgKey{}).(SummaryCfg)
    return cfg, ok
}

summarizer := summary.NewDynamicSummarizer(func(
    ctx context.Context,
    sess *session.Session,
) (summary.SessionSummarizer, error) {
    cfg, ok := SummaryCfgFromContext(ctx)
    if !ok {
        return nil, nil // 本次调用跳过自动摘要。
    }
    return BuildSummarizer(cfg)
})

sessionService, err := mysql.NewService(
    mysql.WithMySQLClientDSN(dsn),
    mysql.WithSummarizer(summarizer),
)

请求执行前,把本次摘要配置放到 ctx

1
2
3
4
ctx = WithSummaryCfg(ctx, SummaryCfg{
    ModelName: req.SummaryModel,
    Prompt:    req.SummaryPrompt,
})

对于同一个 ctx 和 session,resolver 应尽量保持轻量且确定。非强制摘要 场景下,它可能在摘要检查阶段调用一次,在实际生成摘要阶段再调用一次。如果 构造摘要器成本较高,可以把已构造好的摘要器放到 ctx,resolver 只负责读取。 resolver 返回 nil 会跳过自动摘要检查;如果直接调用 Summarize,或在没有 解析到真实摘要器时强制摘要,会返回错误。如果 resolver 在 ShouldSummarizeWithContext 执行自动、非强制摘要检查时返回错误,gate 会将其 当作 false 并跳过摘要生成;直接调用 Summarize 时会把 resolver 错误返回给调用方。

摘要器选项

触发条件

选项 说明
WithEventThreshold(eventCount int) 当自上次摘要后的事件数量超过阈值时触发
WithTokenThreshold(tokenCount int) 当自上次摘要后的 token 数量超过阈值时触发
WithContextThreshold(opts ...ContextThresholdOption) 当自上次摘要后的 token 数量超过当前模型 context window 的指定比例时触发
WithTimeThreshold(interval time.Duration) 在执行摘要检查时,包装 CheckTimeThreshold;当被检查 session 的最后一个事件距离当前已超过该间隔时触发

如果你希望使用固定的业务阈值,例如“不管当前使用什么模型,只要新增 4000 token 就摘要”,使用 WithTokenThreshold。这个阈值会固化在摘要器配置里, 应用切换模型时不会自动变化。

如果摘要触发条件应该跟随当前模型的 context window,使用 WithContextThreshold。对于会在同一 session 中切换模型的 agent,这是更推荐的配置。 每次摘要检查时,框架会按以下顺序解析 context window:

  1. 单次运行覆盖值:agent.WithModelContextWindow(tokens)
  2. 模型实例配置:例如 openai.WithContextWindow(tokens)provider.WithContextWindow(tokens)
  3. 进程级模型名注册表:model.RegisterModelContextWindow(name, tokens)

然后按 contextWindow * ratio 计算阈值(默认 50%)。对于私有部署、endpoint ID、 微调模型、新模型或多租户自定义模型配置,优先使用模型实例或单次运行 option, 避免不同用户覆盖同一个进程级注册表:

modelInstance := openai.New(
    "my-custom-model",
    openai.WithAPIKey(apiKey),
    openai.WithBaseURL(apiURI),
    openai.WithContextWindow(204800),
)

eventChan, err := r.Run(
    ctx,
    userID,
    sessionID,
    userMessage,
    agent.WithModel(modelInstance),
)

eventChan, err = r.Run(
    ctx,
    userID,
    sessionID,
    userMessage,
    agent.WithModelName("my-custom-model"),
    agent.WithModelContextWindow(204800),
)

只有当模型名在当前进程中有稳定的全局含义时,才建议使用全局注册:

model.RegisterModelContextWindow("my-custom-model", 32768)

组合条件

选项 说明
WithChecksAll(checks ...Checker) 要求所有条件都满足(AND 逻辑),使用 Check* 函数
WithChecksAny(checks ...Checker) 任何条件满足即触发(OR 逻辑),使用 Check* 函数
WithChecksAllContext(checks ...ContextChecker) 要求所有带请求上下文的条件都满足(AND 逻辑)
WithChecksAnyContext(checks ...ContextChecker) 任一带请求上下文的条件满足即触发(OR 逻辑)

ContextChecker 的签名为 (ctx context.Context, sess *session.Session)

注意:在 WithChecksAllWithChecksAny 中使用 Check* 函数(如 CheckEventThreshold),而不是 With* 函数。

// AND 逻辑:所有条件都满足才触发
summary.WithChecksAll(
    summary.CheckEventThreshold(10),
    summary.CheckTokenThreshold(2000),
)

// OR 逻辑:任一条件满足即触发
summary.WithChecksAny(
    summary.CheckEventThreshold(50),
    summary.CheckTimeThreshold(10*time.Minute),
)

摘要生成

选项 说明
WithMaxSummaryWords(maxWords int) 限制摘要的最大字数,包含在提示词中指导模型生成
WithPrompt(prompt string) 自定义摘要提示词,必须包含 {conversation_text} 占位符
WithSystemPrompt(prompt string) 为摘要额外添加独立的 system message 指令;不能包含 {conversation_text}
WithCacheSafeForking(enable bool) 在有父请求可用时,启用 cache-safe 摘要请求 forking。默认关闭
WithCacheSafeForkPrompt(prompt string) 自定义 cache-safe fork 模式下追加的压缩 user message。可包含 {max_summary_words},但不能包含 {conversation_text}
WithSkipRecent(skipFunc SkipRecentFunc) 自定义函数跳过最近事件

Hook 选项

选项 说明
WithPreSummaryHook(h PreSummaryHook) 摘要前的 Hook,可修改输入文本
WithPostSummaryHook(h PostSummaryHook) 摘要后的 Hook,可修改输出摘要
WithSummaryHookAbortOnError(abort bool) Hook 报错时是否中断,默认 false(忽略错误)

工具调用格式化

默认情况下,摘要器会将工具调用和工具结果包含在发送给 LLM 进行总结的对话文本中。默认格式为:

  • 工具调用:[Called tool: toolName with args: {"arg": "value"}]
  • 工具结果:[toolName returned: result content]
选项 说明
WithToolCallFormatter(f ToolCallFormatter) 自定义工具调用在摘要输入中的格式。返回空字符串可排除该工具调用
WithToolResultFormatter(f ToolResultFormatter) 自定义工具结果在摘要输入中的格式。返回空字符串可排除该结果
// Truncate long tool arguments
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithToolCallFormatter(func(tc model.ToolCall) string {
        name := tc.Function.Name
        if name == "" {
            return ""
        }
        args := string(tc.Function.Arguments)
        const maxLen = 100
        if len(args) > maxLen {
            args = args[:maxLen] + "...(truncated)"
        }
        return fmt.Sprintf("[Tool: %s, Args: %s]", name, args)
    }),
    summary.WithEventThreshold(20),
)

// Exclude tool results from summary
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithToolResultFormatter(func(msg model.Message) string {
        return ""
    }),
    summary.WithEventThreshold(20),
)

// Include only tool name, exclude arguments
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithToolCallFormatter(func(tc model.ToolCall) string {
        if tc.Function.Name == "" {
            return ""
        }
        return fmt.Sprintf("[Used tool: %s]", tc.Function.Name)
    }),
    summary.WithEventThreshold(20),
)

模型回调(Before/After Model)

summarizer 在调用底层 model.GenerateContent 前后支持模型回调,可用于修改请求、短路返回自定义响应、或在摘要请求上做埋点。

选项 说明
WithModelCallbacks(callbacks *model.Callbacks) 为摘要器的底层模型调用注册 Before/After 回调
callbacks := model.NewCallbacks().
    RegisterBeforeModel(func(ctx context.Context, args *model.BeforeModelArgs) (*model.BeforeModelResult, error) {
        // Modify args.Request, or return CustomResponse to skip the real model call
        return nil, nil
    }).
    RegisterAfterModel(func(ctx context.Context, args *model.AfterModelArgs) (*model.AfterModelResult, error) {
        // Override model output via CustomResponse
        return nil, nil
    })

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithModelCallbacks(callbacks),
)

Checker 函数

Checker 是用于判断是否需要触发摘要的函数类型:

type Checker func(sess *session.Session) bool

内置 Checker

Checker 说明
CheckEventThreshold(eventCount int) 当自上次摘要以来的增量事件数大于阈值时返回 true
CheckTimeThreshold(interval time.Duration) 当被检查 session 的最后一个事件距离当前已超过该间隔时返回 true
CheckTokenThreshold(tokenCount int) 当自上次摘要以来的增量事件提取的对话文本估算 token 数大于阈值时返回 true(通过 TokenCounter 估算,而非 event.Response.Usage.TotalTokens
ChecksAll(checks []Checker) 组合多个 Checker,所有都返回 true 时才返回 true(AND)
ChecksAny(checks []Checker) 组合多个 Checker,任一返回 true 时返回 true(OR)

自定义提示词

customPrompt := `Analyze the following conversation and provide a concise summary,
focusing on key decisions, action items, and important context.
Keep it within {max_summary_words} words.

<conversation>
{conversation_text}
</conversation>

Summary:`

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithPrompt(customPrompt),
    summary.WithMaxSummaryWords(100),
    summary.WithEventThreshold(15),
)

必需占位符

  • {conversation_text}:必须包含,会被对话内容替换
  • {max_summary_words}:当 maxSummaryWords > 0 时,必须包含在 WithPrompt(...)WithSystemPrompt(...) 其中之一

如果希望把摘要指令放到独立的 system message,可以组合使用 WithSystemPrompt 和一个更轻量的 user prompt:

systemPrompt := `请忠实总结这段对话。
重点关注关键决策和待办事项。
请控制在 {max_summary_words} 字以内。`

userPrompt := `<conversation>
{conversation_text}
</conversation>

摘要:`

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithSystemPrompt(systemPrompt),
    summary.WithPrompt(userPrompt),
    summary.WithMaxSummaryWords(100),
    summary.WithEventThreshold(15),
)

说明:

  • WithPrompt 仍然渲染到 user message
  • WithSystemPrompt 会渲染到独立的 system message
  • WithSystemPrompt 不能包含 {conversation_text};对话内容必须保留在 user prompt 中

Token 计数器配置

默认情况下,CheckTokenThreshold 使用内置的 SimpleTokenCounter 基于文本长度估算 token 数量。如果需要自定义 token 计数行为,可以使用 summary.SetTokenCounter 设置全局 token 计数器:

SimpleTokenCounterWithApproxRunesPerToken(v) 表示约 v 个 UTF-8 字符对应 1 个 token,估算公式是 estimatedTokens = countedUTF8Runes / v。例如 v=1.5 表示约 1.5 字符/token;不要把它当成 token 乘数。

import (
    "context"
    "fmt"
    "unicode/utf8"

    "trpc.group/trpc-go/trpc-agent-go/model"
    "trpc.group/trpc-go/trpc-agent-go/session/summary"
)

// Use the built-in simple token counter
summary.SetTokenCounter(model.NewSimpleTokenCounter())

// Or use a custom implementation
type MyCustomCounter struct{}

func (c *MyCustomCounter) CountTokens(ctx context.Context, message model.Message) (int, error) {
    _ = ctx
    return utf8.RuneCountInString(message.Content), nil
}

func (c *MyCustomCounter) CountTokensRange(ctx context.Context, messages []model.Message, start, end int) (int, error) {
    if start < 0 || end > len(messages) || start >= end {
        return 0, fmt.Errorf("invalid range: start=%d, end=%d, len=%d",
            start, end, len(messages))
    }

    total := 0
    for i := start; i < end; i++ {
        tokens, err := c.CountTokens(ctx, messages[i])
        if err != nil {
            return 0, err
        }
        total += tokens
    }
    return total, nil
}

summary.SetTokenCounter(&MyCustomCounter{})

注意

  • 全局影响SetTokenCounter 会影响当前进程中所有的 CheckTokenThreshold 评估,建议在应用初始化时一次性设置
  • 默认计数器:如果不设置,将使用默认的 SimpleTokenCounter(约每 token 对应 4 个字符)
  • 参数语义WithApproxRunesPerToken(v) 中的 v 是字符/token。传入 2.0/3.0 表示约 0.67 字符/token,等价于约 1.5 token/字符

跳过最近事件

使用 WithSkipRecent 可以在摘要时跳过最近的事件:

// 跳过固定数量的事件
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithSkipRecent(func(_ []event.Event) int { return 2 }), // 跳过最后 2 个事件
    summary.WithEventThreshold(10),
)

// 跳过最近 5 分钟的事件(时间窗口)
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithSkipRecent(func(events []event.Event) int {
        cutoff := time.Now().Add(-5 * time.Minute)
        skip := 0
        for i := len(events) - 1; i >= 0; i-- {
            if events[i].Timestamp.After(cutoff) {
                skip++
            } else {
                break
            }
        }
        return skip
    }),
    summary.WithEventThreshold(10),
)

// 只跳过尾部的工具调用消息
summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithSkipRecent(func(events []event.Event) int {
        skip := 0
        for i := len(events) - 1; i >= 0; i-- {
            if events[i].Response != nil && len(events[i].Response.Choices) > 0 &&
                events[i].Response.Choices[0].Message.Role == model.RoleTool {
                skip++
            } else {
                break
            }
        }
        return skip
    }),
    summary.WithEventThreshold(10),
)

摘要 Hook

PreSummaryHook

在摘要生成前调用,可以修改输入文本或事件:

1
2
3
4
5
6
7
8
type PreSummaryHookContext struct {
    Ctx     context.Context
    Session *session.Session
    Events  []event.Event
    Text    string
}

type PreSummaryHook func(in *PreSummaryHookContext) error

PostSummaryHook

在摘要生成后调用,可以修改输出摘要:

1
2
3
4
5
6
7
type PostSummaryHookContext struct {
    Ctx     context.Context
    Session *session.Session
    Summary string
}

type PostSummaryHook func(in *PostSummaryHookContext) error

使用示例

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithPreSummaryHook(func(ctx *summary.PreSummaryHookContext) error {
        // 在摘要生成前修改 ctx.Text 或 ctx.Events
        return nil
    }),
    summary.WithPostSummaryHook(func(ctx *summary.PostSummaryHookContext) error {
        // 在摘要生成后修改 ctx.Summary
        return nil
    }),
    summary.WithSummaryHookAbortOnError(true), // Hook 报错时中断(可选)
)

摘要触发机制

自动触发(推荐)

Runner 在每次对话完成后自动检查触发条件,满足条件时在后台异步生成摘要。

触发时机

  • 事件数量超过阈值(WithEventThreshold
  • Token 数量超过阈值(WithTokenThreshold
  • Token 数量超过当前模型 context window 的指定比例(WithContextThreshold
  • 在一次摘要检查中,被检查 session 的最后一个事件已超过指定时间;在默认增量摘要路径里,这通常就是最近一个待摘要事件(WithTimeThreshold
  • 满足自定义组合条件(WithChecksAny / WithChecksAll

WithTimeThreshold 不是后台定时器。系统不会在“静默满 5 分钟”的瞬间主动生成摘要;只有在执行摘要检查时才会评估,通常发生在一轮对话结束后,或你手动调用摘要 API 时。它判断的是被检查 session 的最后一个事件;在默认增量摘要路径里,这个 session 只包含待摘要增量,所以 5*time.Minute 通常等价于:“到下一次摘要检查时,如果最近一个待摘要事件已经超过 5 分钟,就立即生成摘要。”

手动触发

某些场景下,你可能需要手动触发摘要:

// 异步摘要(推荐)- 后台处理,不阻塞
err := sessionService.EnqueueSummaryJob(
    ctx,
    sess,
    session.SummaryFilterKeyAllContents, // 对完整会话生成摘要
    false,                               // force=false,遵守触发条件
)

// 同步摘要 - 立即处理,会阻塞当前操作
err := sessionService.CreateSessionSummary(
    ctx,
    sess,
    session.SummaryFilterKeyAllContents,
    false, // force=false,遵守触发条件
)

// 异步强制摘要 - 忽略触发条件,强制生成
err := sessionService.EnqueueSummaryJob(
    ctx,
    sess,
    session.SummaryFilterKeyAllContents,
    true, // force=true,绕过所有触发条件检查
)

// 同步强制摘要 - 立即强制生成
err := sessionService.CreateSessionSummary(
    ctx,
    sess,
    session.SummaryFilterKeyAllContents,
    true, // force=true,绕过所有触发条件检查
)

API 说明:

  • EnqueueSummaryJob:异步摘要(推荐)

    • 后台处理,不阻塞当前操作
    • 失败时自动回退到同步处理
    • 适合生产环境
  • CreateSessionSummary:同步摘要
    • 立即处理,会阻塞当前操作
    • 直接返回处理结果
    • 适合调试或需要立即获取结果的场景

参数说明:

  • filterKeysession.SummaryFilterKeyAllContents 表示对完整会话生成摘要
  • force 参数
    • false:遵守配置的触发条件(事件数、token 数、时间阈值等),只有满足条件才生成摘要
    • true:强制生成摘要,完全忽略所有触发条件检查,无论会话状态如何都会执行

使用场景:

场景 推荐 API force 参数
正常对话流程 自动触发(无需调用) -
后台批量处理 EnqueueSummaryJob false
用户主动请求 EnqueueSummaryJob true
调试/测试 CreateSessionSummary true
会话结束时 EnqueueSummaryJob true

上下文注入机制

框架提供两种模式来管理发送给 LLM 的对话上下文:

在选择模式前,先区分三类上下文减载机制:

机制 所在层 改动对象 典型用途
Summary Session Service + prompt assembly 用 LLM 将历史事件生成可持久化摘要;开启 WithAddSessionSummary(true) 后,请求中注入摘要,并只拼接摘要时间点之后的增量事件 长会话保留语义连续性,减少反复发送完整历史
Context compaction Agent prompt assembly 不调用 LLM,不删除整轮消息;只在请求投影阶段改写 tool result 内容,例如旧结果替换为占位符、超大结果首尾保留截断 工具输出很长,但希望尽量保留对话结构和当前轮工具链路
Token tailoring Model provider 模型调用前按 token budget 删除或保留消息轮次,默认策略会尽量保留系统消息和最新轮次,但最终仍受可用预算约束 最后一层兜底,保证请求落入模型 context window

正常调用链路大致是:先由 agent 组装 prompt;如果启用了 WithAddSessionSummary(true),则注入 summary;随后按需压缩 tool result。 如果开启了摘要注入且压完后仍接近 context window,会在 LLM 调用前同步尝试 刷新一次 summary 并重建请求;最后模型层的 token tailoring 再按预算裁剪 消息列表。也就是说,context compaction 和 token tailoring 都能减少 prompt 体积,但前者缩小消息内部的工具输出,后者删减消息轮次;summary 则是用新的 语义摘要替代一段历史。

模式 1:启用摘要注入(推荐)

llmagent.WithAddSessionSummary(true)

工作方式

  • 会话摘要合并到已有的系统消息中(如果存在),否则作为新的系统消息插入到开头
  • 这确保了与要求单条系统消息位于开头的模型兼容(如 Qwen3.5 系列)
  • 包含摘要时间点之后的所有增量事件(不截断)
  • 保证完整上下文:浓缩历史 + 完整新对话
  • WithMaxHistoryRuns 参数被忽略

摘要注入模式

默认情况下,摘要以 system message 的方式注入(合并到已有 system prompt 中)。这种方式下,摘要会被 token tailoring 的 preserved head 保护,不会被滑动窗口裁剪掉。

如果希望摘要能参与 token 预算裁剪,形成真正的滑动窗口效果,可以将注入模式切换为 user

1
2
3
4
5
6
agent := llmagent.New(
    "my-agent",
    llmagent.WithModel(modelInstance),
    llmagent.WithAddSessionSummary(true),
    llmagent.WithSessionSummaryInjectionMode(llmagent.SessionSummaryInjectionUser),
)

两种注入模式的区别

模式 注入位置 Token Tailoring 行为 适用场景
SessionSummaryInjectionSystem(默认) 合并到 system message 摘要在 preserved head 中,不会被裁剪 需要摘要始终存在的场景
SessionSummaryInjectionUser 优先合并到第一条 user history/current message;否则在靠近 history 的位置注入 摘要参与普通轮次裁剪,可被滑动窗口淘汰 超长对话的滑动窗口场景

User 模式的消息结构

当 history 第一条消息为 user role 时,摘要会自动合并进去:

┌─────────────────────────────────────────┐
│ System Prompt                           │ ← 不包含摘要
├─────────────────────────────────────────┤
│ [Few-shot examples, if any]             │
├─────────────────────────────────────────┤
│ User: [summary context] + [original    │
│        first user message]              │ ← 摘要合并到第一条 user history
├─────────────────────────────────────────┤
│ Assistant: ...                          │
│ User: ...                               │
│ ...                                     │
│ User: current message                   │
└─────────────────────────────────────────┘

当 history 第一条消息不是 user role 时,摘要作为独立 user message 插入:

┌─────────────────────────────────────────┐
│ System Prompt                           │ ← 不包含摘要
├─────────────────────────────────────────┤
│ [Few-shot examples, if any]             │
├─────────────────────────────────────────┤
│ User: Context from previous             │
│ interactions: <summary>...</summary>    │ ← 独立的摘要 user message
├─────────────────────────────────────────┤
│ Assistant/Tool history events           │
│ ...                                     │
│ User: current message                   │
└─────────────────────────────────────────┘

注意事项

  • User 模式下,processor 会优先把摘要合并到第一条 user history/current message,让摘要贴近当前生效的 user 轮次
  • 如果没有可合并的 user history/current message,但 prompt 前缀最后一条已经是 user message(例如 injected context),则会回退合并到那条 user message,避免额外再插入一条相邻的 user block
  • User 模式使用更中性的默认文案("Context from previous interactions"),避免以系统指令的语气出现在 user role 中
  • 自定义的 WithSummaryFormatter 同样对 user 模式生效
  • 摘要的生成链路不受影响——注入模式只影响 prompt assembly 层,不影响 summarizer 本身

提示:如果你的场景是超长对话(数百轮),且希望旧摘要能被自然淘汰(被新的摘要替代),建议使用 SessionSummaryInjectionUser 模式。

Context Compaction 细节

Context compaction 不是 summary 的同义词,也不是 token tailoring。它只处理 tool result 这种容易异常膨胀的内容,不会把普通 user/assistant 消息做 LLM 摘要,也不会像 token tailoring 那样直接丢弃完整消息轮次。

命名说明WithEnableContextCompaction(true) 中的 "compaction" 指 prompt-side tool result compaction/pruning;如果需要语义摘要,仍然由 WithAddSessionSummary(true) 和会话摘要器负责。

当开启 WithEnableContextCompaction(true) 时,框架会在真正调用模型前执行两遍压缩:

Pass 1 — 历史 tool result 占位替换ContextCompactionToolResultMaxTokens,默认 1024 tokens):

  • 只作用于旧 request 中超过阈值的 tool result,将其内容整体替换为简短占位符,但保留 ToolIDToolName
  • 当前 request 和最近 ContextCompactionKeepRecentRequests 个已完成 request 不受影响
  • 如果 ToolResultCompactionConfig.SkipRecentFunc 返回正数,尾部这些 event 所属的 request/invocation 也会被视为 recent,从而跳过 Pass 1
  • 适合清理已不重要的历史工具输出

Pass 2 — 超大 tool result 截断ContextCompactionOversizedToolResultMaxTokens默认 0 / 关闭):

  • 作用于几乎所有 tool result,包括当前 request 的session_load 自身返回的恢复结果会被跳过,避免恢复片段再次被压缩
  • 超过阈值的 tool result 会使用首尾保留策略截断:保留内容的开头和结尾,中间插入 [...N characters truncated...] 标记
  • 这是防止单个超大 tool result 直接撑爆 context window 的安全网(例如 web_fetch 返回 800K+ 字符的 HTML)

两遍压缩的定位不同:Pass 1 低阈值、全量替换,激进清理旧历史;Pass 2 高阈值、只在极端情况触发,但能保护当前 request。

Pass 2 默认是关闭的(0),需要满足两个条件才会生效:(1) WithEnableContextCompaction(true) 总开关已打开;(2) ContextCompactionOversizedToolResultMaxTokens > 0(推荐显式传入 8192,可读取常量 processor.DefaultContextCompactionOversizedToolResultMaxTokens)。这样 EnableContextCompaction=false 在语义上始终等于"框架不会修改任何 tool result"。

如果需要按工具名控制行为,可以使用 WithToolResultCompactionConfig(...)

  • ForceCleanToolNames:这些 tool 的历史结果在 context compaction 开启、且 current/recent 保护生效之后会直接替换为策略占位符,适合 shell、grep、日志抓取等高噪声工具
  • KeepToolNames:这些 tool 的结果不会被 context compaction 清理,适合 session_loadsession_search 这类模型可能需要逐字读取的恢复工具
  • SkipRecentFunc:自定义尾部多少个 event 视为 recent,影响 Pass 0 强制清理和 Pass 1 的"历史"判定;Pass 2 仍会处理 recent/current 中的超大 tool result

如果同一个 tool name 同时出现在 ForceCleanToolNamesKeepToolNames 中,KeepToolNames 优先。

当被压缩的事件有 event_id 时,占位符或截断标记会携带 event_idtool_call_idtool_name 等恢复线索。开启 WithEnableOnDemandSession(true) 且后端实现 session.WindowService 后,模型可以调用 session_load,用 content_offset / content_limit 精确加载原始 tool result 的小片段。session_load 的返回大小由它自己的窗口参数和 content_limit 控制;读取超大结果时建议分片加载,而不是一次请求全文。

此外:

  • 如果同时开启了 WithAddSessionSummary(true),并且压完后请求仍接近 context window,会在 LLM 调用前同步执行一次 CreateSessionSummary(...) 并重建 request
  • 模型层的 token tailoring 仍然作为最后兜底。它按消息轮次裁剪,因此恢复片段应保持足够小,避免在最后的模型请求中被整体挤出
  • Context compaction 默认使用 SimpleTokenCounter 估算 token。如果业务使用了针对中文 或特定 provider 的自定义 counter,建议同时通过 WithContextCompactionTokenCounter(...) 传入同一个 counter,让 Pass 1 判断和 Pass 2 截断与模型层 token tailoring 使用一致的估算口径。
counter := model.NewSimpleTokenCounter(
    model.WithApproxRunesPerToken(1.6), // 约 1.6 字符/token;该值是除数,不是乘数
)

modelInstance := openai.New(
    "deepseek-v4-flash",
    openai.WithEnableTokenTailoring(true),
    openai.WithTokenCounter(counter),
)

agent := llmagent.New(
    "my-agent",
    llmagent.WithModel(modelInstance),
    llmagent.WithAddSessionSummary(true),
    llmagent.WithEnableContextCompaction(true), // 只压 tool result,不生成摘要
    llmagent.WithContextCompactionThresholdRatio(0.7),
    llmagent.WithContextCompactionToolResultMaxTokens(1024),  // Pass 1: 旧 tool result → 占位符
    llmagent.WithContextCompactionOversizedToolResultMaxTokens(8192),  // Pass 2: 任意超大 result → 首尾保留截断
    llmagent.WithContextCompactionKeepRecentRequests(1),
    llmagent.WithContextCompactionTokenCounter(counter),
    llmagent.WithToolResultCompactionConfig(&llmagent.ToolResultCompactionConfig{
        ForceCleanToolNames: []string{"shell", "grep"},
        KeepToolNames:       []string{"session_load", "session_search"},
        SkipRecentFunc: func(events []event.Event) int {
            // 例如保护最后 3 个 event 对应的 request/invocation,
            // 避免仍在收尾的工具链路被 Pass 1 当成历史清理。
            return 3
        },
    }),
)

完整示例见 examples/context_compaction。 该示例会调用真实模型,默认通过 -debug=true 打印每次实际发送给模型的 request,用来检查历史大 tool result 是否按预期被替换为占位符。

上下文结构

┌─────────────────────────────────────────┐
│ System Prompt                           │
│ (merged with Session Summary)           │ ← 系统提示 + 浓缩历史
├─────────────────────────────────────────┤
│ Event 1 (after summary)                 │ ┐
│ Event 2                                 │ │
│ Event 3                                 │ │ 摘要后的新事件
│ ...                                     │ │ (完整保留)
│ Event N (current message)               │ ┘
└─────────────────────────────────────────┘

模型兼容性

部分 LLM 提供商对系统消息的位置和数量有严格要求:

  • Qwen3.5 系列等模型要求系统消息必须位于对话开头,且不支持多条系统消息
  • 默认的合并行为可避免 System message must be at the beginning 等错误
  • 预加载的内存内容也会通过相同机制合并到系统消息中

模式 2:不使用摘要

llmagent.WithAddSessionSummary(false)
llmagent.WithMaxHistoryRuns(10)  // 限制历史轮次

工作方式

  • 不添加摘要消息
  • 只包含最近 MaxHistoryRuns 轮对话
  • MaxHistoryRuns=0 时不限制,包含所有历史
  • 如果开启 WithEnableContextCompaction(true),保留下来的旧 request 中超长 tool result 会在 request projection 阶段被压缩(Pass 1);如果同时显式设置 WithContextCompactionOversizedToolResultMaxTokens(8192)(或其他正值),任意 request 中的超大 tool result 会被首尾保留截断(Pass 2)。两者都需要 EnableContextCompaction=true 总开关
  • 这个模式下不会触发 pre-LLM 的同步摘要重试

上下文结构

1
2
3
4
5
6
7
8
┌─────────────────────────────────────────┐
│ System Prompt                           │
├─────────────────────────────────────────┤
│ Event N-k+1                             │ ┐
│ Event N-k+2                             │ │ Last k runs
│ ...                                     │ │ (MaxHistoryRuns=k)
│ Event N (current message)               │ ┘
└─────────────────────────────────────────┘

模式选择建议

场景 推荐配置 说明
长期会话(客服、助手) AddSessionSummary=true 保持完整上下文,优化 token
短期会话(单次咨询) AddSessionSummary=false
MaxHistoryRuns=10
简单直接,无需摘要开销
调试测试 AddSessionSummary=false
MaxHistoryRuns=5
快速验证,减少干扰
高并发场景 AddSessionSummary=true
增加 worker 数量
异步处理,不影响响应速度

如果你的长会话里经常出现搜索结果、日志、代码扫描输出这类长 tool result,建议开启 EnableContextCompaction=true。如果你还希望在接近 context window 时多一次同步摘要兜底,再配合 AddSessionSummary=true 一起使用。

提示:如果你的 agent 使用了 web_fetch 等可能单次返回超大结果的工具,ContextCompactionOversizedToolResultMaxTokens 尤为重要——它能防止单个 tool result 吃光整个 context window,即使该 result 属于当前正在处理的(受保护的)request。它默认关闭,需要显式开启 WithEnableContextCompaction(true) 并设置一个正阈值(推荐 8192)才会生效。

摘要格式自定义

默认情况下,会话摘要会以包含上下文标签和关于优先考虑当前对话信息的提示进行格式化:

默认格式

1
2
3
4
5
6
7
Here is a brief summary of your previous interactions:

<summary_of_previous_interactions>
[Summary content]
</summary_of_previous_interactions>

Note: this information is from previous interactions and may be outdated. You should ALWAYS prefer information from this conversation over the past summary.

您可以使用 WithSummaryFormatter 来自定义摘要格式:

1
2
3
4
5
6
7
8
agent := llmagent.New(
    "my-agent",
    llmagent.WithModel(modelInstance),
    llmagent.WithAddSessionSummary(true),
    llmagent.WithSummaryFormatter(func(summary string) string {
        return fmt.Sprintf("## Previous Context\n\n%s", summary)
    }),
)

使用场景

  • 简化格式:使用简洁的标题和最少的上下文提示来减少 token 消耗
  • 语言本地化:将上下文提示翻译为目标语言
  • 角色特定格式:为不同的 Agent 角色提供不同的格式
  • 模型优化:根据特定模型的偏好调整格式

获取摘要

// 获取完整会话摘要(默认)
summaryText, found := sessionService.GetSessionSummaryText(ctx, sess)
if found {
    fmt.Printf("摘要: %s\n", summaryText)
}

// 获取特定 filter key 的摘要
userSummary, found := sessionService.GetSessionSummaryText(
    ctx, sess, session.WithSummaryFilterKey("user-messages"),
)
if found {
    fmt.Printf("用户消息摘要: %s\n", userSummary)
}

Filter Key 支持

  • 不提供选项时,返回全量会话摘要(SummaryFilterKeyAllContents
  • 提供特定 filter key 但未找到时,回退到全量会话摘要
  • 如果都不存在,兜底返回任意可用的摘要

按事件类型生成摘要

在实际应用中,你可能希望为不同类型的事件生成独立的摘要。

使用 AppendEventHook 设置 FilterKey

sessionService := inmemory.NewSessionService(
    inmemory.WithAppendEventHook(func(ctx *session.AppendEventContext, next func() error) error {
        // 根据事件作者自动分类
        prefix := "my-app/"  // 必须添加 appName 前缀
        switch ctx.Event.Author {
        case "user":
            ctx.Event.FilterKey = prefix + "user-messages"
        case "tool":
            ctx.Event.FilterKey = prefix + "tool-calls"
        default:
            ctx.Event.FilterKey = prefix + "misc"
        }
        return next()
    }),
)

FilterKey 前缀规范

⚠️ 重要:FilterKey 必须添加 appName + "/" 前缀。

原因:Runner 在过滤事件时使用 appName + "/" 作为过滤前缀,如果 FilterKey 没有这个前缀,事件会被过滤掉。

1
2
3
4
5
// ✅ 正确:带 appName 前缀
evt.FilterKey = "my-app/user-messages"

// ❌ 错误:没有前缀,事件会被过滤掉
evt.FilterKey = "user-messages"

为不同类型生成摘要

1
2
3
4
5
6
7
8
9
// 为用户消息生成摘要
err := sessionService.CreateSessionSummary(ctx, sess, "my-app/user-messages", false)

// 为工具调用生成摘要
err := sessionService.CreateSessionSummary(ctx, sess, "my-app/tool-calls", false)

// 获取特定类型的摘要
userSummary, found := sessionService.GetSessionSummaryText(
    ctx, sess, session.WithSummaryFilterKey("my-app/user-messages"))

限制摘要目标

默认情况下,当某个非空分支 FilterKey 触发摘要时,session service 会同时 刷新该分支摘要和全量会话摘要(SummaryFilterKeyAllContents)。如果某些分支 不需要摘要,可以通过 allowlist 控制范围,并按需关闭全量摘要级联:

1
2
3
4
5
6
7
8
sessionService := inmemory.NewSessionService(
    inmemory.WithSummarizer(summarizer),
    inmemory.WithSummaryFilterAllowlist(
        "my-app/user-messages",
        "my-app/tool-calls",
    ),
    inmemory.WithCascadeFullSessionSummary(false),
)

行为说明:

  • WithSummaryFilterAllowlist(...) 只控制非空分支摘要目标,不会阻止 session.SummaryFilterKeyAllContents 这个全量摘要目标。
  • WithCascadeFullSessionSummary(...) 控制非空分支触发摘要时,是否同时刷新 全量会话摘要。
  • 如果只想保留 branch 触发出来的全量摘要,不写任何 branch 摘要,可以显式传入 空 allowlist,并保持默认 cascade 开启:
1
2
3
4
5
sessionService, err := mysql.NewService(
    mysql.WithMySQLClientDSN(dsn),
    mysql.WithSummarizer(summarizer),
    mysql.WithSummaryFilterAllowlist(""),
)
  • mysql.WithSummaryFilterAllowlist("")mysql.WithSummaryFilterAllowlist() 都表示“不允许任何 branch key”; 在默认 cascade 行为下,仍然会刷新全量会话摘要。
  • 如果同时设置 mysql.WithCascadeFullSessionSummary(false),非空 branch 触发时 就没有任何摘要目标,因此不会生成摘要。
  • allowlist 使用带分隔符的层级匹配,不是原始字符串前缀匹配。框架内部会先给 两边补上 filter key 分隔符("/"),再判断两者是否处于同一条祖先/子孙 层级路径上。
  • 例子:
    • 放行 my-app/tool 时,会匹配 my-app/toolmy-app/tool/search
    • 放行 my-app/tool/search 时,也会匹配 my-app/tool
    • 放行 my-app/tool 时,不会匹配 my-app/toolbox
    • 放行 my-app/tool 时,不会匹配 other-app/tool
  • 即使配置了 allowlist,session.SummaryFilterKeyAllContents 仍然可以被直接 用于生成全量会话摘要。
  • 不配置 allowlist 时会保持兼容行为,所有分支 FilterKey 都可以触发摘要。
  • 显式传入空 allowlist 会阻止 branch 摘要目标;如果 cascade 开启,branch 触发时仍会刷新全量会话摘要。

工作原理

  1. 增量处理:摘要器跟踪每个会话的上次摘要时间,后续运行只处理上次摘要后发生的事件
  2. 增量摘要:新事件与先前的摘要组合,生成一个既包含旧上下文又包含新信息的更新摘要
  3. 触发条件评估:在生成摘要之前,评估配置的触发条件。如果条件未满足且 force=false,则跳过摘要
  4. 异步 Worker:摘要任务使用基于哈希的分发策略分配到多个 worker goroutine,确保同一会话的任务按顺序处理
  5. 回退机制:如果异步入队失败(队列已满、上下文取消或 worker 未初始化),系统会自动回退到同步处理

最佳实践

  1. 选择合适的阈值:如果 agent 运行时可能切换模型,优先使用 WithContextThreshold;如果你明确需要固定 token 预算,再使用 WithTokenThreshold。对于自定义模型或租户提供的模型,优先使用模型级 WithContextWindow 或单次运行的 agent.WithModelContextWindow;只有稳定的进程级模型名才使用全局注册
  2. 使用异步处理:在生产环境中始终使用 EnqueueSummaryJob 而不是 CreateSessionSummary,以避免阻塞对话流程
  3. 监控队列大小:如果频繁看到"queue is full"警告,请增加 WithSummaryQueueSizeWithAsyncSummaryNum
  4. 自定义提示词:根据应用需求定制摘要提示词。例如,如果你正在构建客户支持 Agent,应关注关键问题和解决方案
  5. 平衡字数限制:设置 WithMaxSummaryWords 以在保留上下文和减少 token 使用之间取得平衡。典型值范围为 100-300 字
  6. 测试触发条件:尝试不同的 WithChecksAnyWithChecksAll 组合,找到摘要频率和成本之间的最佳平衡

性能考虑

  • LLM 成本:每次摘要生成都会调用 LLM,监控触发条件以平衡成本和上下文保留
  • 内存使用:摘要与事件一起存储,配置适当的 TTL 以管理长时间运行会话中的内存
  • 异步 Worker:更多 worker 会提高吞吐量但消耗更多资源,从 2-4 个 worker 开始,根据负载进行扩展
  • 队列容量:根据预期的并发量和摘要生成时间调整队列大小

完整示例

以下是演示所有组件如何协同工作的完整示例:

package main

import (
    "context"
    "time"

    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/model"
    "trpc.group/trpc-go/trpc-agent-go/model/openai"
    "trpc.group/trpc-go/trpc-agent-go/runner"
    "trpc.group/trpc-go/trpc-agent-go/session/inmemory"
    "trpc.group/trpc-go/trpc-agent-go/session/summary"
)

func main() {
    ctx := context.Background()

    // Create LLM model for chat and summary
    llm := openai.New("gpt-4", openai.WithAPIKey("your-api-key"))

    // Create summarizer with flexible trigger conditions
    summarizer := summary.NewSummarizer(
        llm,
        summary.WithMaxSummaryWords(200),
        summary.WithChecksAny(
            summary.CheckEventThreshold(20),
            summary.CheckTokenThreshold(4000),
            summary.CheckTimeThreshold(5*time.Minute), // 在摘要检查时判断;比较被检查 session 的最后一个事件(在增量摘要路径里通常就是最近一个待摘要事件)
        ),
    )

    // Create session service with summarizer
    sessionService := inmemory.NewSessionService(
        inmemory.WithSummarizer(summarizer),
        inmemory.WithAsyncSummaryNum(2),
        inmemory.WithSummaryQueueSize(100),
        inmemory.WithSummaryJobTimeout(60*time.Second),
    )

    // Create agent with summary injection enabled
    agent := llmagent.New(
        "my-agent",
        llmagent.WithModel(llm),
        llmagent.WithAddSessionSummary(true),
        llmagent.WithMaxHistoryRuns(10),
    )

    // Create runner
    r := runner.NewRunner("my-app", agent,
        runner.WithSessionService(sessionService))

    // Run conversation - summary will be managed automatically
    userMsg := model.NewUserMessage("Tell me about AI")
    eventChan, _ := r.Run(ctx, "user123", "session456", userMsg)

    // Consume events
    for event := range eventChan {
        _ = event
    }
}

参考资源