跳转至

会话摘要(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), // Runner 路径:下一请求到来前的空闲时间超过 5 分钟时触发
    ),
    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),
)

WithAsyncSummaryNum 只控制后台异步摘要 worker 的并发数,不是同步/异步模式开关,也不是关闭摘要功能的开关。如果不需要生成摘要,不配置 WithSummarizer 即可;如果需要在同一次 Run 的长 ReAct loop 中让下一次 LLM 调用前立刻看到最新摘要,应在 Agent 侧使用 llmagent.WithSyncSummaryIntraRun(true)

步骤 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)

主示例保持默认异步摘要路径。只有在长 ReAct loop 需要同一次 Run 内下一次 LLM 调用前立刻看到最新摘要时,才显式添加同步 intra-run 选项:

1
2
3
4
5
6
7
llmAgent := llmagent.New(
    "my-agent",
    llmagent.WithModel(summaryModel),
    llmagent.WithAddSessionSummary(true),   // 启用摘要注入
    llmagent.WithSyncSummaryIntraRun(true),
    llmagent.WithMaxHistoryRuns(10),        // 当AddSessionSummary=false时限制历史轮次
)

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

Cache-Safe 摘要 Forking

摘要器有两种请求构造模式。

独立摘要请求 是默认模式。框架会先选出需要摘要的 events,把它们转换成 conversation text;如果配置了 WithPreSummaryHook(...),还会先执行这个 hook。随后摘要模型收到的请求由下面两部分组成:

  • 可选的 system message,来自 WithSystemPrompt(...)
  • 一条 user message,来自 WithPrompt(...);其中 {conversation_text} 会被替换为提取出的对话文本。自定义 prompt 还可以使用 {previous_summary},把上一版滚动摘要与本次新增的对话事件分别放置。

这条请求和主 agent 的请求相互独立,因此同步摘要、异步摘要、手动调用摘要接口 都能使用。

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

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

在普通 LLM flow 里触发 context compaction 时,框架已经构造好了当前主 agent 调用的父 model.Request。开启 WithCacheSafeForking(true) 后,摘要请求会按 下面的方式构造:

  • 克隆这个父请求,保留它对模型可见的 prefix,包括 system context、已注入的 summary、session history、用户输入、工具定义、headers、extra fields 和 generation settings。
  • 在末尾追加一条 user message,内容来自 WithCacheSafeForkPrompt(...)
  • 强制摘要调用使用非流式输出,并清掉 structured output,因为摘要调用只需要返回普通摘要文本。

这样摘要请求和父请求拥有相同的前缀,支持 prompt cache 的模型服务就能复用更多 已缓存输入。如果当前没有父请求,例如手动或外部调用摘要接口,摘要器会自动 回退到独立摘要请求。

无论最终使用哪种请求,发送前都会按摘要模型的有效输入预算做准入检查:如果模型 能够提供 provider-specific input budget,框架会取它与“模型 context window 的 70%”这层保守上限中的较小值。fork 请求超预算时,框架只修改 clone,不会污染父 请求:先移除摘要调用不会使用的 tool schemas,再按完整 source round 从旧到新 缩减并保护最新一轮,必要时替换较大的 tool arguments/results payload。如果仍然 放不下,再重建为 bounded standalone 请求。standalone fallback 会对 {conversation_text}{previous_summary} 两块 payload 做首尾保留截断, 固定的 system prompt 和 user prompt 模板不会被截坏。

预算适配和 fork → standalone 的选择发生在 BeforeModel callback 之前,因此 callback 看到并修改的就是最终准备送模的请求。callback 返回后框架会再次计数; 如果 callback 自己把请求扩到超预算,会明确失败,而不是再次换请求并静默丢失 callback 的修改。如果 provider 仍返回 context-length error,或者非 custom 的 模型调用返回空 summary,摘要器会用第一次输入预算的一半再做一次 bounded standalone 重试。

这里有一个重要的 branch 摘要行为:开启 WithCacheSafeForking(true) 后,非空 branch 触发摘要时,可以用当前父请求 fork 来生成 branch 摘要;但同一轮 summary pass 不会再跑级联出来的全量会话摘要。框架会直接跳过这个全量摘要目标,而不是 回退到独立的全量摘要 prompt,也不会复用这个 branch 视角的 fork request。如果 需要覆盖所有 branch 的全量摘要,需要单独触发一次全量会话摘要。

Prompt 规则:

  • WithPrompt(...) 配置独立摘要请求的 user prompt,必须包含 {conversation_text},并可选包含 {previous_summary}。使用该可选占位符时, {previous_summary} 是上一版滚动摘要,{conversation_text} 只包含摘要边界后 新增的事件;不使用时,上一版摘要继续合并在 {conversation_text} 中以保持兼容。 如果配置了 WithMaxSummaryWords(...){max_summary_words} 必须出现在 WithPrompt(...)WithSystemPrompt(...) 其中之一。
  • WithSystemPrompt(...) 配置独立摘要请求里可选的 system message,不能包含 {conversation_text}{previous_summary},可以包含 {max_summary_words}
  • WithCacheSafeForkPrompt(...) 只配置 fork 模式下追加的 user message,不能 包含 {conversation_text}{previous_summary},因为克隆出来的父请求里已经有 对话内容和已注入的摘要;它可以包含 {max_summary_words}

即使开启了 cache-safe forking,也要保持独立摘要 prompt 有效,因为 fallback 路径仍然会使用它。自定义 fork prompt 时,建议明确要求模型“总结上面的对话, 供后续继续对话使用”,并保留用户目标、决策、约束、未完成事项、工具结果和重要 事实;同时要求模型不要调用工具、不要直接回答最新用户请求,也不要把 system 和 tool-use 指令当作事实写进摘要。

WithPreSummaryHook(...) 仍然会在摘要模型调用前执行。独立摘要模式下,hook 修改后的文本会渲染进 {conversation_text}。当 prompt 使用 {previous_summary} 时,hook 的 EventsText 是本次新增对话, PreviousSummary 则是可以单独修改的上一版摘要。如果 fork 模式拿到了父请求, 这些 payload 修改不会再被塞进摘要请求,因为对话内容已经在克隆的父请求里。 此时 hook 仍可用于更新 context、做副作用处理,以及服务 fallback 到独立摘要请求的场景。

在 fork 模式下,WithPreSummaryHook(...) 对 text 或 events 的修改不会对克隆 出来的父请求做脱敏、redaction 或 filtering。如果这个 hook 用于在摘要前做脱敏 或过滤,请让这类流程使用独立摘要模式,或确保父 model.Request 在被克隆前已经 完成脱敏。

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) Runner 路径按当前顶层 request 到来前的空闲间隔判断;standalone 调用保留“最后事件距现在多久”的兼容行为

如果你希望使用固定的业务阈值,例如“不管当前使用什么模型,只要新增 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%)。为了避免在极短上下文中 过早摘要,WithContextThreshold 默认还会施加 2000 token 的最小触发阈值。 也就是说,实际阈值是 max(contextWindow * ratio, minTokenThreshold),且内置 checker 只有在估算 token 数大于阈值时才触发。如果把 ratio 设置得很小, 例如 0.001,但希望 1000 token 左右就开始摘要,需要显式传入 summary.WithContextThresholdMinTokens(0),或设置成业务希望的最小值。

触发和调用上报

如果需要观察“为什么触发 summary”以及“summary 模型请求实际用了多少 token”, 可以配置 summary.WithReportHook

summarizer := summary.NewSummarizer(
    summaryModel,
    summary.WithContextThreshold(),
    summary.WithReportHook(func(ctx context.Context, report summary.Report) {
        triggerTokens := report.Trigger.Value
        summaryPromptTokens := report.Call.PromptTokens
        _ = triggerTokens
        _ = summaryPromptTokens
    }),
)

Report 会把两个 token 口径拆开:

  • report.Trigger.Value:触发 checker 使用的值,例如上次 summary 之后增量事件的估算 token 数
  • report.Call.EstimatedPromptTokens:框架在发起 summary 模型请求前,对完整请求做的本地估算
  • report.Call.PromptTokens:summary 模型返回的官方 usage.prompt_tokens

开启 cache-safe forking 时,report.Call.Modecache_safe_fork,请求估算值来自 fork 后的父请求加上追加的 summary 指令。普通独立 summary prompt 模式下,mode 为 standalone。 如果 BeforeModel callback 返回 custom response,实际没有发送 summary 模型请求,mode 为 custom_response,prompt 估算值保持为 0。

高级集成如果要在高层 summary 流程前放入同一个 report,可以使用 summary.ContextWithReport(ctx, report),需要从 context 取出时使用 summary.ReportFromContext(ctx)。单一路径会复用这个 report;cascade 并行生成多个 summary 时,框架会给每个 worker 克隆一份 report,避免不同分支同时写同一个对象。 这些 fork 出来的 report 会通过各自调用的 hook 发出,不会再合并回 root report。

对于私有部署、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)

常用 ContextThresholdOption

选项 说明
WithContextThresholdRatio(ratio float64) 设置 context window 的触发比例,默认 0.5
WithContextThresholdMinTokens(tokens int) 设置绝对最小触发 token 数,默认 2000;传入 0 可以取消这层下限
WithContextThresholdFallbackWindow(tokens int) 设置 summary checker 的兜底 context window,默认 8192。在 WithContextThreshold 路径中,不传这个选项时,框架会尽量从 summarizer model 推导 fallback;显式传入后会使用你的值,并跳过 summarizer model fallback。实际检查时,只有当运行上下文、模型实例和注册表都无法解析 context window 时才使用该 fallback;这不同于 token tailoring 对未知模型使用的 128000 fallback

组合条件

选项 说明
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},可选包含 {previous_summary}
WithSystemPrompt(prompt string) 为摘要额外添加独立的 system message 指令;不能包含 {conversation_text}{previous_summary}
WithCacheSafeForking(enable bool) 在有父请求可用时,启用 cache-safe 摘要请求 forking。默认关闭
WithCacheSafeForkPrompt(prompt string) 自定义 cache-safe fork 模式下追加的压缩 user message。可包含 {max_summary_words},但不能包含 {conversation_text}{previous_summary}
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) Runner 摘要路径检查当前顶层 request 到来前的空闲间隔;没有 Runner observation 的直接调用保留 last-event-age 行为
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),
)

Prompt 占位符

  • {conversation_text}:必须包含,会被对话内容替换
  • {previous_summary}:可选,用于把上一版滚动摘要与摘要边界后的新增事件分开; 第一次摘要时为空。不使用该占位符时,上一版摘要仍会合并进 {conversation_text},保持原有行为
  • {max_summary_words}:当 maxSummaryWords > 0 时,必须包含在 WithPrompt(...)WithSystemPrompt(...) 其中之一

如果希望在增量摘要中单独放置上一版摘要,可以这样写:

userPrompt := `请根据新增对话更新上一版摘要。

<previous_summary>
{previous_summary}
</previous_summary>

<new_conversation>
{conversation_text}
</new_conversation>

更新后的摘要:`

{previous_summary} 适用于 standalone 请求和 cache-safe fallback 请求。 cache-safe fork 成功时会直接使用克隆的父请求,摘要在父请求中的位置不会由该占位符改变。

如果希望把摘要指令放到独立的 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}{previous_summary};对话内容必须保留在 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 乘数。

Token 估算取舍

内置 SimpleTokenCounter 只按 UTF-8 字符数做轻量估算,默认 4.0 字符/token 更接近英文文本的经验值。中文、日文、韩文以及中英文混合内容通常会偏离这个比例, 建议结合业务压测或线上观测按模型和语料校准,例如把 WithApproxRunesPerToken 调整到更保守的 1.22.0 区间。

框架没有默认调用模型厂商的精确 token API:很多模型的 tokenizer 并未开源, tokenizer 也会随模型版本演进;如果在每次摘要检查中远程调用 token 统计接口, 会引入额外延迟、成本和限流风险,而且不同 provider 的接口能力也不一致。 因此 summary checker 使用可替换的本地估算器作为快速 gate。如果业务需要更高精度, 建议实现 model.TokenCounter,并在应用初始化时通过 summary.SetTokenCounter 统一设置。

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 在每次对话完成后自动检查触发条件,满足条件时在后台异步生成摘要。

如果启用了 WithSyncSummaryIntraRun(true),Flow 会在同一次 Run 的 LLM 迭代之间同步调用 CreateSessionSummary(...),确保下一次 LLM 调用前可以使用最新摘要。中间 tool result 的冗余异步入队会被跳过;最终 assistant response 仍然可以入队一个摘要任务,用于刷新本轮结束后的摘要。在 async worker 可用且队列有容量时,该任务会在后台执行;如果没有配置 async worker 或队列已满,EnqueueSummaryJob 可能 fallback 到同步摘要创建,因此这不是严格的非阻塞保证。同步路径与异步 worker 共享同一套 boundary/delta 判断和进程内 session/filterKey 级别的串行控制,在单进程内通常可以避免对同一批事件重复发起昂贵的 LLM 摘要,但它不是跨实例分布式锁。

触发时机

  • 事件数量超过阈值(WithEventThreshold
  • Token 数量超过阈值(WithTokenThreshold
  • Token 数量超过当前模型 context window 的指定比例(WithContextThreshold
  • 当前顶层 request 到来前的空闲间隔超过阈值(Runner 路径中的 WithTimeThreshold
  • 满足自定义组合条件(WithChecksAny / WithChecksAll

WithTimeThreshold 不是后台定时器。Runner 自动路径会在顶层 request 到达时固定记录时间,并将它与同一摘要 scope 中的上一条相关事件比较。例如,5*time.Minute 表示:“下一次顶层 request 在该 scope 静默超过 5 分钟后到达时,由这次 request 引发的摘要检查可以触发。”模型响应耗时和异步 worker 排队时间不会计入 gap。没有 Runner request observation 的直接 checker 或摘要 API 调用保留原有的 last-event-age 行为。

同轮同步摘要(长 ReAct loop)

默认自动触发路径是异步的:Runner 追加符合条件的完整响应事件后(例如 tool result 或最终 assistant response)入队 summary job,由后台 worker 再按触发条件决定是否生成摘要。user message、tool-call response、无效内容、 SkipSummarization 事件,以及 sync summary 下的中间 tool result 不会入队异步 summary job。这个模式能保护主链路延迟,但在同一次 Run 包含多轮 LLM/tool 迭代时,后台摘要可能来不及在下一次 LLM 调用前完成。

如果你的 agent 经常在同一次 Run 内连续调用工具,并且工具结果可能快速推高 上下文长度,可以开启同轮同步摘要:

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

开启后,Flow 会在同一次 Run 的 LLM loop 之间同步执行一次摘要检查。它调用 CreateSessionSummary(..., force=false),因此不会绕过摘要器配置的事件数、token 数、时间或 context window 阈值;只有条件满足时才会真正生成摘要。配合 WithAddSessionSummary(true) 时,下一次 LLM 请求会注入刚刷新的 summary;对于普通 已完成历史,通常只拼接 summary 边界之后的增量事件。但在同一次 Run 内,请求组装仍 可能保留或压缩边界前必要的 tool-call/tool-result 消息,以维持当前 ReAct 工具链的合法 结构。

为避免重复工作,开启同轮同步摘要后,中间 tool result 事件会跳过冗余的异步 summary 入队;最终 assistant response 仍然可以触发异步 job,让本轮结束后的 session summary 保持最新。这个选项不会替代跨 Run 的默认异步摘要行为。

同轮同步摘要会把一次可能的 summary LLM 调用放进主链路,适合长 ReAct loop、coding agent、连续大工具输出或接近 context window 的场景;一般在线问答和低延迟场景仍建议 优先使用默认异步摘要。

手动触发

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

// 异步摘要(推荐)- 后台处理,不阻塞
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 系列)
  • 包含摘要时间点之后的所有增量事件。如果同步 intra-run summary 在当前 invocation 内推进了 boundary,重建请求时还会保留当前 user message,以及 cutoff 前最新一个完整 tool round 作为有界 resume tail
  • 通过浓缩历史、cutoff 后事件和当前 invocation 的有界 resume tail 保持语义连续;更早且已被覆盖的 tool rounds 只由 summary 表达
  • 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 的位置注入 摘要参与普通轮次裁剪,可被滑动窗口淘汰;也更利于保持稳定 system 前缀 超长对话、prompt cache 敏感场景

Memory preload 和 session recall preload 有各自独立的 placement 设置。为了兼容 已有用户,它们默认仍注入 system context,因此会留在 token tailoring 的 preserved head 中。如果 cache-sensitive 场景希望把它们也放到 user/history 路径,需要显式 opt in:

1
2
3
4
5
6
7
8
agent := llmagent.New(
    "my-agent",
    llmagent.WithModel(modelInstance),
    llmagent.WithPreloadMemory(10),
    llmagent.WithPreloadMemoryInjectionMode(llmagent.PreloadMemoryInjectionUser),
    llmagent.WithPreloadSessionRecall(5),
    llmagent.WithPreloadSessionRecallInjectionMode(llmagent.PreloadSessionRecallInjectionUser),
)

User placement 能让稳定 system 前缀更利于 prompt cache,但 preloaded memory 和 recalled session events 会参与 token tailoring,可能被裁剪。

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) 时,框架会在真正调用模型前按配置执行下面几类 tool result 压缩:

Pass 0 — 按工具名强制占位替换ForceCleanToolNames,默认未配置):

  • 只作用于命中 ForceCleanToolNames 的历史 tool result,不需要超过 ContextCompactionToolResultMaxTokens 阈值
  • 当前 request、recent 保护集合中的 request/invocation 不受影响;KeepToolNames 优先级更高
  • 适合清理 shell、grep、日志抓取等高噪声工具的历史输出

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

  • 只作用于旧 request 中超过阈值的 tool result,将其内容整体替换为简短占位符,但保留 ToolIDToolName
  • current/recent 保护集合不受影响;这个集合包含当前 request、最近 ContextCompactionKeepRecentRequests 个已完成 request,以及 ToolResultCompactionConfig.SkipRecentFunc 返回的尾部 event 所属 request/invocation
  • SkipRecentFuncContextCompactionKeepRecentRequests 是并集关系;如果希望完全由自定义 recent 逻辑控制,可将 ContextCompactionKeepRecentRequests 设为 0
  • 适合清理已不重要的历史工具输出

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 0 是显式工具名策略;Pass 1 低阈值、全量替换,激进清理旧历史;Pass 2 高阈值、只在极端情况触发,也可能作用于当前 request。

同步 intra-run summary 还有一条专门的请求投影规则。如果新 summary boundary 覆盖到了当前 invocation 的 events,那么普通已覆盖历史以 boundary 为硬边界, 但重建后的主 agent 请求仍会保留:

  1. 当前 invocation 的 user message。
  2. cutoff 前最新一个完整 tool round;并行 tool calls 及其匹配 results 会整批保留。
  3. cutoff 后的全部增量 events。

框架只恢复这一个最新的完整 tool round;更早的已覆盖 tool rounds 只由 summary 表达。这个小型 resume tail 能避免主模型把已完成的工具步骤误判为“尚未执行”, 进而重复有副作用的调用。开启 context compaction 后,恢复出来的每个 tool-call arguments payload,以及每个未被 keep 规则保护的 tool result,都会分别与 ContextCompactionToolResultMaxTokens 比较;只替换单项超限的内容,并保留 tool ID、名称和 call/result 配对。关闭 context compaction 时,框架不会改写这些 payload。如果 cutoff 正好落在 tool call 和 result 中间,原有的配对修复仍会补齐 协议结构,但不会带回无关的已覆盖历史。

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

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

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

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

当 Pass 1 占位符或 Pass 2 截断标记对应的事件有 event_id 时,会携带 event_idtool_call_idtool_name 等恢复线索;Pass 0 的策略占位符不携带这些恢复线索。开启 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)           │ ← 系统提示 + 浓缩历史
├─────────────────────────────────────────┤
│ User: current invocation message        │ ← intra-run cutoff 后仍保留
├─────────────────────────────────────────┤
│ Latest complete pre-cutoff tool round   │ ← 最多一轮;超大 payload 可替换为占位符
├─────────────────────────────────────────┤
│ Event 1 (after summary)                 │ ┐
│ Event 2                                 │ │ 摘要后的增量 events
│ ...                                     │ │ (受已配置 compaction/tailoring 约束)
│ Event N                                 │ ┘
└─────────────────────────────────────────┘

模型兼容性

部分 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(...) 控制非空分支触发摘要时,是否同时刷新 全量会话摘要。
  • 开启 WithCacheSafeForking(true) 后,如果当前有父请求可 fork,branch 触发的 summary pass 只会生成 branch 摘要;级联出来的全量会话摘要目标会被跳过,不会 回退到独立的全量摘要 prompt,也不会复用这个 branch 视角的 fork request。如果 确实需要覆盖所有 branch 的全量摘要,请单独触发一次全量会话摘要。
  • 如果只想保留 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), // Runner 路径:下一请求到来前的空闲时间超过 5 分钟时触发
        ),
    )

    // 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
    }
}

参考资源