# Context 管理 ## 文档维护规范 0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖 1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name` 2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `docs/decisions.md` 另行记录 --- ## 概述 Context = 每次 LLM 调用时发送的完整消息列表。管理目标: 1. **注入**:确保模型在正确的时间看到正确的信息(system prompt、plan、skill 策略) 2. **压缩**:在 token 预算内去除冗余,保留核心上下文 两者互相影响:压缩可能清掉已注入的内容,需要重新注入。 --- ## Context 构成 ``` [system] System Prompt(角色 + 内置 skills) ← 静态注入,trace 创建时 [user] 第一条用户消息 [assistant] ... [tool] ... ← 可能包含周期注入的 context [assistant] ... [tool] ... ← 可能包含动态注入的 skill [user] 第 N 条用户消息 [assistant] 待模型生成 ``` --- ## 注入机制 ### 1. System Prompt(静态注入) Trace 创建时构建一次,后续续跑不重复发送。 内容:角色 prompt + 按 preset/call-site 过滤的内置/项目 skills。 优先级:`messages 中的 system message` > `config.system_prompt` > `preset.system_prompt` > `DEFAULT_SYSTEM_PREFIX` **实现**:`agent/core/runner.py:_build_system_prompt` --- ### 2. Context 周期注入(当前实现) 每 `CONTEXT_INJECTION_INTERVAL`(当前=5)轮迭代,框架自动向模型的 tool_calls 中追加一条 `get_current_context` 调用(如果模型本轮未主动调用)。 注入内容(`_build_context_injection` 构建): - 当前时间 - GoalTree(精简视图)+ focus 提醒 - Active Collaborators 状态 - IM 消息通知(如有) - Context Injection Hooks 注册的自定义内容 注入形态:**合成的 tool_call + tool_result 消息对**。模型看到的效果等同于"我之前主动调用了 get_current_context"。 ``` [assistant] tool_calls: [..., get_current_context()] ← 框架自动追加 [tool] { 当前时间, GoalTree, Collaborators... } ← 工具正常执行 ``` **特点**: - 固定间隔,不做去重(每次内容都是最新状态快照) - 仅在主路径执行(侧分支中跳过) - 检查模型是否已主动调用,避免重复 **实现**:`agent/core/runner.py`(`CONTEXT_INJECTION_INTERVAL`, 工具执行后的自动注入逻辑) **详细文档**:[架构设计 § Context Injection Hooks](./architecture.md#context-injection-hooks上下文注入钩子) --- ### 3. Skill 指定注入(设计中,未实现) 区别于模型通过 `skill()` 工具主动加载 skill,指定注入是**调用方在 `runner.run()` 时声明**需要哪些 skill,由框架自动注入。 #### 动机 System Prompt 中的内置 skills 是启动时一次性注入的。但某些 skill(如 Librarian 的查询策略/上传策略): - 不是每次都需要(按任务类型按需加载) - 在长期续跑的 agent 中会被压缩掉,需要重新注入 - 应该和现有的 `skill` 工具统一管理 #### 启用方式 调用方通过 `runner.run()` 的 `inject_skills` 参数声明(不放在 RunConfig 中,因为同一个 agent 的不同调用可能需要不同的 skill): ```python # Librarian ask 场景 async for item in runner.run( messages=[{"role": "user", "content": "[ASK] 查询内容"}], config=config, inject_skills=["ask_strategy"], # 本次调用需要的 skill skill_recency_threshold=10, # 最近 N 条消息内有就不重复注入 ): ... # Librarian upload 场景(同一个 agent,不同 skill) async for item in runner.run( messages=[{"role": "user", "content": "[UPLOAD:BATCH] 数据"}], config=config, inject_skills=["upload_strategy"], skill_recency_threshold=10, ): ... ``` #### 注入机制 框架在工具执行阶段自动注入(与 context 周期注入同级处理)。 **注入形态**:合成的 `skill("xxx")` tool_call + tool_result 消息对,复用现有 `skill` 工具。 ``` [user] [ASK] 有没有工具能做人物姿态控制? [assistant] tool_calls: [skill("ask_strategy")] ← 框架自动生成 [tool] { ask_strategy skill 完整内容 } ← skill 工具正常执行 [assistant] 让我来检索... ← 模型真实输出 ``` #### 防重复:message ID 追踪 在 `trace.context` 中维护已注入 skill 的记录: ```python trace.context["injected_skills"] = { "ask_strategy": { "message_id": "msg_abc123", # tool_result 消息的 ID "sequence": 42 # 最新注入的消息序列号 } } ``` 注入前检测流程: 1. 查 `injected_skills[skill_id].message_id` 2. 检查该 message_id 是否仍在当前提交给模型的消息列表中 3. 在且 sequence 在最近 `recency_threshold` 条消息内(从当前提交给模型的列表中查,不按编号计算) → 跳过 4. 不在(被压缩掉)或超过阈值 → 重新注入,更新记录 #### Skill 的两种加载方式 | 方式 | 触发者 | 场景 | |---|---|---| | 指定注入(本节) | 调用方,通过 `run(inject_skills=...)` | 已知任务类型,确保策略在场 | | 主动加载 | 模型自己调 `skill()` 工具 | 遇到未预见场景,自主判断需要什么 | 两者都走 `skill` 工具,消息格式一致。 #### Skill 文件位置 指定注入的 skill 文件遵循现有 skill 体系(Markdown + frontmatter),按项目组织: ``` knowhub/agents/skills/ # KnowHub Librarian 的 skills ├── ask_strategy.md # 查询检索策略 └── upload_strategy.md # 上传编排策略 ``` --- ## 压缩机制 压缩分两级,通过 `RunConfig.goal_compression` 控制 Level 1 行为: | 模式 | 值 | Level 1 行为 | Level 2 行为 | |------|---|---|---| | 不压缩 | `"none"` | 跳过 Level 1 | 超限时直接进入 Level 2 | | 完成后压缩 | `"on_complete"` | 每个 goal 完成时立刻压缩该 goal 的消息 | 超限时进入 Level 2 | | 超长时压缩 | `"on_overflow"` | 超限时遍历所有 completed goal 逐个压缩 | Level 1 后仍超限则进入 Level 2 | 默认值:`"on_overflow"` ### Level 1:Goal 完成压缩(确定性,零 LLM 成本) 对单个 completed/abandoned goal 的压缩逻辑: 1. **识别目标消息**:找到该 goal 关联的所有消息(`msg.goal_id == goal.id`) 2. **区分 goal 工具消息和非 goal 消息**:检查 assistant 消息的 tool_calls 中是否调用了 `goal` 工具 3. **保留 goal 工具消息**:保留所有调用 `goal(...)` 的 assistant 消息及其对应的 tool result(包括 add、focus、under、done 等操作) 4. **删除非 goal 消息**:从 history 中移除该 goal 的其他 assistant 消息及其 tool result(read_file、bash、search 等中间工具调用) 5. **替换 done 的 tool result**:将 `goal(done=...)` 的 tool result 内容替换为 `"具体执行过程已清理"` 6. **纯内存操作**:压缩仅操作内存中的 history 列表,不涉及新增消息或持久化变更,原始消息永远保留在存储层 压缩后的 history 片段示例: ``` ... (前面的消息) [assistant] tool_calls: [goal(focus="1.1")] [tool] goal result: "## 更新\n- 焦点切换到: 1.1\n\n## Current Plan\n..." [assistant] tool_calls: [goal(done="1.1", summary="前端使用 React...")] [tool] goal result: "具体执行过程已清理" ... (后面的消息) ``` #### `on_complete` 模式 在 goal 工具执行 `done` 操作后,立刻对该 goal 执行压缩。优点是 history 始终保持精简,缺点是如果后续需要回溯到该 goal 的中间过程,信息已丢失(存储层仍保留原始消息)。 **触发点**:`agent/core/runner.py`(工具执行后检测 `goal(done=...)` 调用) #### `on_overflow` 模式 在 `_manage_context_usage` 检测到 token 超限时,遍历所有 completed goal,逐个执行压缩,直到 token 数降到阈值以下或所有 completed goal 都已压缩。如果仍超限,进入 Level 2。 **触发点**:`agent/core/runner.py:_manage_context_usage` **实现**:`agent/trace/compaction.py:compress_completed_goals` ### Level 2:LLM 摘要压缩 触发条件:Level 1 之后 token 数仍超过阈值(默认 `context_window × 0.5`)。 通过侧分支队列机制执行,`force_side_branch` 为列表类型: 1. **反思**(可选,由 `knowledge.enable_extraction` 控制):进入 `reflection` 侧分支,LLM 可多轮调用工具提取知识 2. **知识评估**(可选):进入 `knowledge_eval` 侧分支,评估待评估知识 3. **压缩**:进入 `compression` 侧分支,LLM 生成 summary 侧分支队列示例: - 启用知识提取:`["reflection", "compression"]` - 有待评估知识:`["knowledge_eval", "compression"]` - 仅压缩:`["compression"]` 压缩完成后重建 history 为:`system prompt + 第一条 user message + summary(含详细 GoalTree)` **实现**:`agent/core/runner.py:_agent_loop`(侧分支状态机), `agent/core/runner.py:_rebuild_history_after_compression` ### 任务完成后反思 主路径无工具调用(任务完成)时,如果 `knowledge.enable_completion_extraction` 为 True,通过侧分支进入反思,完成后退出循环。 ### GoalTree 双视图 `to_prompt()` 支持两种模式: - `include_summary=False`(默认):精简视图,用于日常周期性注入 - `include_summary=True`:含所有 completed goals 的 summary,用于 Level 2 压缩时提供上下文 ### 压缩存储 - 原始消息永远保留在 `messages/` - 压缩 summary 作为普通 Message 存储 - 侧分支消息通过 `branch_type` 和 `branch_id` 标记,查询主路径时自动过滤 - 通过 `parent_sequence` 树结构实现跳过,无需 compression events 或 skip list - Rewind 到压缩区域内时,summary 脱离主路径,原始消息自动恢复 --- ## 注入与压缩的交互 压缩会移除消息,包括之前注入的 skill 和 context 内容。不同注入类型的应对策略: | 注入类型 | 压缩影响 | 恢复策略 | |---------|---------|---------| | System Prompt | Level 2 重建时保留 | 无需额外处理 | | Context 周期注入 | 可能被 Level 1/2 移除 | 固定间隔自动重注入(下一个 interval 即恢复) | | Skill 动态注入 | 可能被 Level 1/2 移除 | message ID 检测 → 自动重注入 | Skill 重注入的触发点:每次 runner 准备构建 LLM 调用前,检查 `injected_skills` 中记录的 message_id 是否仍在当前 history 中。不在则标记为需要重新注入,在下一轮工具执行阶段合成 tool_call。 --- ## 相关文档 | 文档 | 内容 | |------|------| | [架构设计](./architecture.md) | Agent 框架完整架构 | | [Skills 指南](./skills.md) | Skill 文件格式、分类、加载机制 | | [Prompt 规范](./prompt-guidelines.md) | 信息分层、条件注入原则 | | [Trace API](./trace-api.md) | 压缩和反思的 REST API |