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。
同样的思想还适用于:
- 文档摘要与 evidence ID;
- 批准决定与被批准的动作参数;
- 新事实与它覆盖的旧版本标识;
- 表格标题与对应数据片段。
依赖不存在时项目直接抛错,而不是静默忽略。静默忽略会把数据建模错误伪装成“预算不足”。
5. 新鲜、相关和可信不是同一个维度
最新用户消息可能包含提示注入,最新工具结果可能只是超时错误,最相似的记忆可能已经过期。生产候选通常还需要:
| 字段 | 解决的问题 |
|---|---|
source_trust |
工具事实、用户陈述和模型摘要怎样区分 |
expires_at |
旧监控什么时候不再有效 |
conflicts_with |
新旧事实冲突时怎样处理 |
sensitivity |
有预算但无权限的材料怎样阻止 |
evidence_id |
结论怎样回查原始数据 |
本项目只实现 priority、freshness、dependency 和 protected,目的是让核心算法可读。它不是生产检索与授权系统。
6. 长工具结果不能作为一个大候选
5000 行日志如果被包装成一个 Candidate,只有“全部进入”或“全部丢弃”两个选择。更合理的路径是:
原始日志
-> 按时间、服务和请求 ID 分块
-> 去重与统计
-> 异常片段 + evidence ID
-> selector 选择少量片段
-> 必要时工具按 ID 回查原文
这不是把日志事实交给摘要模型随意改写。确定性统计、结构化字段和原始证据引用应尽量保留;模型摘要只能作为有来源的派生材料。
7. 失败注入
- 把预算从 70 降到无法容纳工具组,确认
tool-call和tool-result同时出现在 dropped; - 删除
tool-result候选,确认 unknown dependency 导致明确错误; - 建立两个相同 priority/freshness 的候选,确认稳定 ID 让结果可复现;
- 建立一个 protected 项,但让它本身大于总预算,决定系统应该拒绝还是请求缩小任务,而不是悄悄删掉保护项。
项目测试已经固定了两个关键不变量:依赖组原子选择、protected 项优先。运行:
python -m unittest courses.foundation.context-engineering.course.project.tests.test_selection -v
8. 本章验收
你应能从选择报告回答:每个候选来自哪里;谁给它 priority;依赖是否完整;为什么被丢弃;原始证据如何回查。你还应能解释为什么“向量相似度最高”不能直接等于“最应该进入上下文”。