Context Builder 模式

独立的上下文组装模块,负责给定 session_id + turn_id + current_user_message,产出本轮 LLM 调用所需的完整上下文包。核心原则:不要把上下文拼接逻辑散落在各处。

#type / concept #status / evergreen #tech / ai #tech / architecture

[!info] related notes

Context Builder 模式

Context Builder 是一个独立的上下文组装模块。它的职责是:给定 session_id + turn_id + current_user_message,产出本轮 LLM 调用所需的完整上下文包。核心原则是不要把上下文拼接逻辑散落在 chat_handler、consultation_graph、build_messages、前端 SSE 处理里。

为什么需要独立模块

没有 Context Builder 时,上下文拼接逻辑通常散落在:

  • Go handler 里加载 messages
  • Python 端 build_messages 函数
  • 前端 SSE 处理里的状态同步
  • prompt 模板里的隐式假设

这导致:

  1. 改一处影响多处 — 比如想调整消息过滤逻辑,要改 Go 和 Python 两个地方
  2. 测试困难 — 无法单独测试”给定这些输入,上下文包是否正确”
  3. 跨服务边界模糊 — Python 端直接查业务数据库,职责混乱
  4. 上下文不一致 — 不同轮次、不同代码路径组装出的上下文格式不同

职责边界

┌─────────────────────────────────────────────┐
│  Go 后端(业务主服务)                        │
│                                             │
│  - 读取数据库(session, messages, profile)   │
│  - 过滤和筛选消息                             │
│  - 构建结构化问诊状态                         │
│  - 加载知识库检索结果                         │
│  - 控制 token 预算                           │
│  - 组装 Context Bundle                       │
│                                             │
│  → ContextBuilder 在这里                     │
└──────────────────┬──────────────────────────┘
                   │ Context Contract(JSON)

┌─────────────────────────────────────────────┐
│  Python AI Service(Agent 编排服务)          │
│                                             │
│  - 接收 Context Bundle                      │
│  - Agent 推理和 LangGraph 流程               │
│  - 生成回复(流式)                           │
│  - 提取结构化信息                             │
│  - 决定下一步动作                             │
│                                             │
│  → 不负责查业务数据库                         │
└─────────────────────────────────────────────┘

关键决策:Go 更接近 session、messages、user profile、extracted_info、database、auth、turn_id、message status,所以 ContextBuilder 应该在 Go 端。Python 不应该到处查业务库,否则边界会乱。

数据加载步骤

第一步:加载原始数据

加载以下数据源:

数据源说明
consultation_session会话元数据
messages历史消息
extracted_info已提取的结构化信息
user_profile用户画像
conversation_summary对话摘要(如果有)
agent_stateAgent 流程状态

第二步:消息过滤

不要简单 SELECT * FROM messages 然后全塞给模型。 必须过滤:

过滤规则原因
只包含 completed 的用户消息排除 pending 状态的未完成消息
只包含 completed 的 assistant 消息排除 failed / canceled / partial 消息
排除当前 turn 的消息避免重复(当前消息单独传)
排除纯 UI 事件如 phase_changed、typing 等非内容事件
tool call 结果只保留摘要完整结果可能过长

SSE 场景特殊注意:assistant 回复是流式生成的,如果中途断流,数据库里可能有半截消息。半截消息不能作为下一轮可靠上下文。

func filterMessages(messages []Message, currentTurnID string) []Message {
    var filtered []Message
    for _, msg := range messages {
        // 排除当前 turn
        if msg.TurnID == currentTurnID {
            continue
        }
        // 只保留 completed 状态
        if msg.Status != "completed" {
            continue
        }
        // 排除纯 UI 事件
        if msg.Type == "ui_event" {
            continue
        }
        filtered = append(filtered, msg)
    }
    return filtered
}

第三步:构建结构化状态

不要让模型每轮都靠自然语言记忆。详见 问诊状态 Schema

第四步:上下文窗口控制

不能永远把所有历史都塞进去。详见 上下文窗口预算

概念结构

type ConsultationContextBundle struct {
    SessionID string `json:"session_id"`
    TurnID    string `json:"turn_id"`

    CurrentUserMessage string `json:"current_user_message"`

    RecentMessages      []ChatMessage `json:"recent_messages"`
    ConversationSummary string        `json:"conversation_summary,omitempty"`

    ConsultationState ConsultationState `json:"consultation_state"`

    UserProfile *UserProfile `json:"user_profile,omitempty"`

    RetrievedKnowledge []KnowledgeChunk `json:"retrieved_knowledge"`

    ContextMeta ContextMeta `json:"context_meta"`
}

type ContextMeta struct {
    TokenBudget          int    `json:"token_budget"`
    IncludedMessageCount int    `json:"included_message_count"`
    SummaryVersion       int    `json:"summary_version,omitempty"`
    ContextBuildVersion  string `json:"context_build_version"`
}

组装流程伪代码

func (b *ContextBuilder) Build(ctx context.Context, sessionID, turnID, userMessage string) (*ConsultationContextBundle, error) {
    // 1. 加载会话和消息
    session, _ := b.repo.GetSession(ctx, sessionID)
    messages, _ := b.repo.GetMessages(ctx, sessionID)

    // 2. 过滤消息
    filtered := filterMessages(messages, turnID)

    // 3. 分离 recent 和 summary
    recent, summary := b.splitRecentAndSummary(filtered)

    // 4. 加载结构化状态
    state, _ := b.repo.GetConsultationState(ctx, sessionID)

    // 5. 加载用户画像
    profile, _ := b.repo.GetUserProfile(ctx, session.UserID)

    // 6. 检索知识库(可选,按需)
    knowledge := b.retrieveKnowledge(ctx, userMessage, state)

    // 7. 控制 token 预算
    recent = b.trimToFitTokenBudget(recent, summary, state, knowledge)

    // 8. 组装 bundle
    return &ConsultationContextBundle{
        SessionID:           sessionID,
        TurnID:              turnID,
        CurrentUserMessage:  userMessage,
        RecentMessages:      recent,
        ConversationSummary: summary,
        ConsultationState:   state,
        UserProfile:         profile,
        RetrievedKnowledge:  knowledge,
        ContextMeta: ContextMeta{
            TokenBudget:          b.tokenBudget,
            IncludedMessageCount: len(recent),
            ContextBuildVersion:  "1.0.0",
        },
    }, nil
}

测试策略

Context Builder 应该有独立的单元测试:

测试场景验证点
第二轮对话历史消息正确包含第一轮
断流消息failed/partial 的 assistant 消息不进入上下文
当前 turn 排除当前 turn 的消息不重复出现
长对话超过阈值时触发 summary,只保留最近 N 轮
空会话第一轮没有历史消息时不报错
token 超限自动裁剪到预算内

在项目中的位置

apps/api
  internal/
    ai_context/
      builder.go           ← ContextBuilder 主逻辑
      message_filter.go    ← 消息过滤规则
      token_budget.go      ← token 预算控制
      consultation_state.go ← 状态加载和转换
创建于 2026/6/30 更新于 2026/7/15