KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04. 预算不足时先保留什么? — keel 龙骨

预算计划告诉我们“还能放 127 个 token”,却没有回答应该放什么。支付事故当前有五份候选:用户目标、最新监控、两周前事故、一条工具调用和它的结果。如果空间只够其中三份,简单保留最新消息可能把工具调用与结果拆开,也可能丢掉用户最初要求的证据约束。

预算计划告诉我们“还能放 127 个 token”,却没有回答应该放什么。支付事故当前有五份候选:用户目标、最新监控、两周前事故、一条工具调用和它的结果。如果空间只够其中三份,简单保留最新消息可能把工具调用与结果拆开,也可能丢掉用户最初要求的证据约束。

Context Selection 的任务不是把字符串按分数排序,而是在权限和预算约束内,构造一个仍然能让 Agent 正确继续的工作集。

1. 先把原材料变成候选

原始日志、历史消息和记忆不能直接进入排序器。一个可审计候选至少需要:

item_id       稳定 ID,用于去重、审计和回放
kind          task / evidence / memory / tool-call / tool-result
priority      业务规则给出的优先级
dependencies  必须一起出现的其他候选 ID
protected     普通预算淘汰是否允许删除
freshness     新鲜度或版本
source_ref    原始事实的可回查位置

项目把它表示为不可变的 Candidate:

@dataclass(frozen=True)
class Candidate:
    item_id: str
    content: str
    kind: str
    priority: int = 0
    dependencies: tuple[str, ...] = ()
    protected: bool = False
    freshness: int = 0
    source_ref: str | None = None

这些字段有不同的信任来源。item_id 和租户作用域应由程序生成,不能让模型自行填写;priority 来自业务策略;freshness 来自数据版本;内容可能来自用户、工具或记忆,可信程度并不相同。

2. 选择之前先做安全过滤

推荐顺序是:

身份/租户/ACL 过滤
  -> 过期和删除状态过滤
  -> 候选标准化与去重
  -> 依赖分组
  -> 预算内稳定选择
  -> 装配为模型消息

相似度高、时间新、内容短,都不能绕过权限。历史事故与当前问题很相似,但如果属于另一个租户,它连候选都不应该成为。Selector 不是授权系统的最后补丁,而是在授权完成后的材料调度器。

3. 一次完整选择

示例建立五份候选:

candidates = [
    Candidate("task", "当前任务:判断支付服务是否恢复",
              "task", priority=100, protected=True),
    Candidate("fresh", "最新监控:error rate 下降到 0.1%",
              "evidence", priority=90, freshness=10),
    Candidate("old", "两周前的事故:曾经出现类似超时",
              "memory", priority=20),
    Candidate("tool-call", "search_incident(query=payment)",
              "tool-call", priority=80, dependencies=("tool-result",)),
    Candidate("tool-result", "证据 ref=INC-42,时间 2026-08-22",
              "tool-result", priority=80),
]

运行:

python courses/foundation/context-engineering/course/project/examples/02_select_context.py

当前输出:

selected: ['task', 'fresh', 'tool-call', 'tool-result', 'old']
used/remaining: 29 41
dropped: []

预算 70 足以放下全部材料。真正值得观察的是顺序:protected task 先进入;新监控次之;工具调用通过 dependency 把结果作为一个组带入;低优先级旧事故最后进入。

算法按 (protected, priority, freshness, stable_id) 排序。稳定 ID 是最终 tie-breaker,保证相同输入得到相同结果,方便测试和回放。

4. 为什么依赖必须原子选择

工具调用和结果存在结构依赖:

assistant: call search_incident, call_id=c1
tool:      result for call_id=c1

只保留调用,模型会等待一个已经发生但看不到的结果;只保留结果,供应商可能认为它是 orphan message 并拒绝请求。Selector 因此计算整个依赖组的 token:要么全部进入,要么全部记录为 dropped。

同样的思想还适用于:

依赖不存在时项目直接抛错,而不是静默忽略。静默忽略会把数据建模错误伪装成“预算不足”。

5. 新鲜、相关和可信不是同一个维度

最新用户消息可能包含提示注入,最新工具结果可能只是超时错误,最相似的记忆可能已经过期。生产候选通常还需要:

字段 解决的问题
source_trust 工具事实、用户陈述和模型摘要怎样区分
expires_at 旧监控什么时候不再有效
conflicts_with 新旧事实冲突时怎样处理
sensitivity 有预算但无权限的材料怎样阻止
evidence_id 结论怎样回查原始数据

本项目只实现 priority、freshness、dependency 和 protected,目的是让核心算法可读。它不是生产检索与授权系统。

6. 长工具结果不能作为一个大候选

5000 行日志如果被包装成一个 Candidate,只有“全部进入”或“全部丢弃”两个选择。更合理的路径是:

原始日志
  -> 按时间、服务和请求 ID 分块
  -> 去重与统计
  -> 异常片段 + evidence ID
  -> selector 选择少量片段
  -> 必要时工具按 ID 回查原文

这不是把日志事实交给摘要模型随意改写。确定性统计、结构化字段和原始证据引用应尽量保留;模型摘要只能作为有来源的派生材料。

7. 失败注入

  1. 把预算从 70 降到无法容纳工具组,确认 tool-call 和 tool-result 同时出现在 dropped;
  2. 删除 tool-result 候选,确认 unknown dependency 导致明确错误;
  3. 建立两个相同 priority/freshness 的候选,确认稳定 ID 让结果可复现;
  4. 建立一个 protected 项,但让它本身大于总预算,决定系统应该拒绝还是请求缩小任务,而不是悄悄删掉保护项。

项目测试已经固定了两个关键不变量:依赖组原子选择、protected 项优先。运行:

python -m unittest courses.foundation.context-engineering.course.project.tests.test_selection -v

8. 本章验收

你应能从选择报告回答:每个候选来自哪里;谁给它 priority;依赖是否完整;为什么被丢弃;原始证据如何回查。你还应能解释为什么“向量相似度最高”不能直接等于“最应该进入上下文”。

下一章:选择仍然放不下时怎样压缩?

进入 keel 阅读