Skip to content

上下文工程:窗口预算、压缩与子代理隔离

工具写好了、图搭起来了、可靠性也兜住了,任务一长还是会崩——几十个信源、上百次工具调用、跨天接力,窗口先撑不住。这篇讲怎么把上下文窗口当成一种需要精细分配的稀缺资源来管,以及为什么顶开这个天花板的不是更大的模型,是更好的骨架。

窗口是新的天花板

单轮问答用不着这一篇:一个问题、三段检索、一个答案,窗口绰绰有余。撑不住是从"任务变长"开始的,症状有四种,它们的危险程度递增。

症状表现直接原因危险在哪
溢出报 context length exceeded,或被框架静默截断装配时根本没算账报错发生在生成节点,根因在几步之前
截断失忆第 20 轮忘了第 1 轮说的"只寄公司地址"滑动窗口把老消息裁掉了用户说过的话要反复说,体感极差
迷航长任务跑到中段开始重复劳动、偏离原目标原始任务描述被挤到很远的位置步数和成本都上去了,结果还是错的
上下文中毒一个早期的错误结论被反复引用,越用越确信错的东西进了上下文、摘要或长期记忆自我强化,越往后越难纠正

七个入口里,前端出身的人通常只盯着"历史"和"检索",但实测下来工具返回值往往是最大的那一桶——它是唯一一个"你不设上限它就真的没有上限"的来源:一个订单列表接口返回 200 条 × 60 个字段,一次就能吃掉半个窗口。

判断标准: 只要任务会跨越 10 步以上、或者会跨会话接力,就必须显式管上下文;单轮问答不必。管的方式不是"想办法塞更多进去",而是让每个 token 都值得占那个位置。


窗口更大不等于效果更好

"换个 200k 窗口的模型不就完了" 是最常见的第一反应。它能买到时间,但买不到效果,原因有四条,前两条是效果问题,后两条是成本问题。

变大之后实际发生什么为什么
中间信息丢失放在上下文中段的关键事实,模型经常"看不见"注意力对开头和结尾更敏感,中段是低敏感区(lost in the middle)
噪声稀释塞 40 段材料的准确率低于精选 8 段相关信号占比下降,模型要先在无关内容里做筛选,而它不擅长这件事
成本线性上涨每一步都重发全量上下文,循环里乘以步数Agent 的每一轮决策都要带完整历史,这是循环结构的固有代价
首字延迟上涨输入越长,prefill 越久,首字越慢长输入的编码时间随长度增长,流式体验先受影响(见 流式输出

一个反直觉的经验

同一个任务,把材料从 30 段砍到 8 段精选,准确率常常上升而不是下降。所以**"上下文塞不满"不是浪费,"塞满了"才需要解释**。窗口是预算不是目标。

生产推荐: 大窗口模型留给两类场景——单份长文档必须整体理解(合同、财报),以及压缩摘要器本身需要一次读进很多历史。其余场景优先把材料做精,而不是把窗口做大。


上下文账本:先量出来再动手

优化上下文的第一步不是压缩,是知道 token 花在哪了。没有账本的优化都是猜——猜完往往去裁历史,而真正的大头在工具返回值。

计量要能换 tokenizer:本地开发用近似估算,上线前换成模型真实的 tokenizer,两者在中文长文本上能差 20% 以上。

python
from dataclasses import dataclass, field
from typing import Protocol

class Tokenizer(Protocol):
    def count(self, text: str) -> int: ...

class Approx:
    """兜底估算:中文约 1.5 字/token,英文约 4 字符/token。只用于本地开发"""
    def count(self, text: str) -> int:
        cjk = sum(1 for c in text if "一" <= c <= "鿿")
        return int(cjk / 1.5 + (len(text) - cjk) / 4) + 1

BUCKETS = ("system", "tools_schema", "tool_results", "retrieved", "history", "memory", "query")

@dataclass
class ContextLedger:
    """上下文账本:按来源分桶计量,装配层每加一段就记一笔"""
    limit: int                       # 模型窗口
    reserve_out: int                 # 给输出预留的额度,不参与分配
    tk: Tokenizer
    used: dict[str, int] = field(default_factory=lambda: dict.fromkeys(BUCKETS, 0))

    def add(self, bucket: str, text: str) -> int:
        n = self.tk.count(text)
        self.used[bucket] += n
        return n

    @property
    def usable(self) -> int: return self.limit - self.reserve_out
    @property
    def total(self) -> int: return sum(self.used.values())
    @property
    def ratio(self) -> float: return self.total / max(self.usable, 1)

    def zone(self) -> str:
        r = self.ratio
        return "safe" if r < 0.6 else "warn" if r < 0.85 else "danger"

    def top(self, n: int = 3) -> list[tuple[str, int]]:
        return sorted(self.used.items(), key=lambda kv: -kv[1])[:n]   # 大头是谁

水位三区与各区的动作

水位动作谁触发
安全< 60%什么都不做,只记账——
警戒60% - 85%工具返回值切引用模式、历史裁到近 N 轮、检索 Top-K 下调一档装配层自动执行
危险> 85%触发压缩;压完仍超则外置到工作区,或把子任务外包给子代理压缩节点
溢出> 100%不该出现。出现即 fail fast,别交给 SDK 去截断装配层断言

最后一行是硬要求:不要依赖框架或 SDK 的静默截断。 它裁掉的通常是最前面的系统提示或最后面的当前问题,前者让约束失效、后者让模型答错题,两种都比直接报错更难查。

python
def assemble(state, ledger: ContextLedger) -> list:
    """装配层:先记账再决定给多少。顺序很重要——先放不可裁的,再放可裁的"""
    parts = [("system", SYSTEM_PROMPT), ("query", state["question"])]      # 这两桶不裁
    for b, text in parts:
        ledger.add(b, text)

    zone = ledger.zone()
    keep_turns = {"safe": 12, "warn": 6, "danger": 3}[zone]
    top_k = {"safe": 8, "warn": 5, "danger": 3}[zone]
    tool_mode = "full" if zone == "safe" else "ref"                        # 见下一节
    ...
    if ledger.total > ledger.usable:            # 兜底断言:宁可报错也不静默截断
        raise ContextOverflow(f"账本 {ledger.total} > 可用 {ledger.usable},大头:{ledger.top()}")
    return messages

一份典型的实测分布(比例会随业务大幅变化,但排序很稳定):工具返回值 40%-55%、检索材料 15%-25%、对话历史 10%-20%、工具 schema 5%-15%、系统提示 3%-8%、长期记忆 1%-3%。排序稳定这件事本身就是行动指南:先动最大的那桶,别一上来就压历史。

对话历史那一桶在 Multi-Agent 里给过一张按优先级分配的预算表,可以直接当默认值用;本篇讲的是那张表撑不住之后怎么办。


工具返回值整形:性价比最高的一招

这一节放在最前面是因为它无损、可预测、改一处包装层就生效,而且往往一次砍掉三到五成。工具层的定义、错误处理、幂等见 Tool Calling,这里只讲"返回多少、怎么返"。

三板斧

手段做法适用代价
字段裁剪60 个字段只返模型真正要看的 6 个几乎所有内部接口无损,但要维护字段白名单
分页只返第一页 + next_cursor + total列表类查询模型要翻页时多一跳
引用 + 按需取全文ref_id + 摘要 + 可取字段清单,要细节再调 fetch(ref_id)大 JSON、长文档、报表多一跳,工具要成对设计

字段裁剪常常比截条数更划算,而且完全无损:先把 60 个字段砍到 6 个,很多时候连分页都不需要了。

省略必须显式,否则模型会替你谎报

python
# 反例:静默掐尾
def shape_bad(rows: list[dict]) -> str:
    return json.dumps(rows[:20], ensure_ascii=False)     # 模型无从知道后面还有 197 条

模型拿到 20 条订单,回答"您近期共有 20 笔订单,其中 3 笔待发货"。实际有 217 笔、58 笔待发货。它没有幻觉,它只是把你给它的当成了全部。 这类 badcase 在评测里极难发现:格式正确、语气自然、数字看起来有据可依,只有对照真实数据才知道是错的。

python
def shape(rows: list[dict], *, total: int, limit: int = 20,
          fields: tuple[str, ...] = ()) -> dict:
    """返回值必须自带完整性元信息:模型要能看出自己拿到的是不是全部"""
    page = [{k: r.get(k) for k in fields} if fields else r for r in rows[:limit]]
    truncated = total > len(page)
    return {
        "items": page,
        "returned": len(page), "total": total,          # 关键:总数一定要给
        "truncated": truncated, "omitted": max(total - len(page), 0),
        "next_cursor": rows[limit].get("id") if truncated else None,
        "hint": (f"仅返回前 {len(page)} 条,共 {total} 条。回答时不得把这 {len(page)} 条"
                 f"当作全部;需要总数或分类统计请调用 count_orders,需要更多明细请用 "
                 f"next_cursor 翻页。" if truncated else ""),
    }

hint 不是冗余的元数据,而是给模型的行为约束。光给 truncated: true 模型不一定会照做,要把"不得当作全部""要统计就调聚合工具"明确写出来。

生产推荐: 聚合类问题的根本解法不是让模型数列表,而是给它一个聚合工具。count_orders(status="待发货") 返回一个数字,比返回 217 条明细再让模型自己数既准又便宜几十倍。凡是发现模型在数东西,就该补一个聚合工具。

踩坑: 超长字符串(一段 500KB 的日志、一份 HTML 全文)不能只截断,还要在工具层就设硬上限并显式标注。截断标记要放在中间而不是尾部——前 2000 字 + …[已省略 N 字符]… + 后 500 字,这样堆栈的报错行和日志的结尾都还在。


压缩:什么时候压、压什么、怎么压

压缩(compaction)是把一段历史换成一段摘要。它能腾出最多空间,也是唯一不可逆的手段——所以它在本篇的优先级排最后。

什么时候压

触发方式阈值优点风险
水位触发> 85%只在必要时才付摘要的钱时机不可预测,可能压在关键推理的中途
轮数 / 步数触发每 N 步可预测,便于测试可能压得过早,白花一次调用
阶段边界触发(推荐)子任务完成时语义完整,摘要质量最高需要流程里有明确的阶段划分

生产推荐: 阶段边界为主、水位为兜底。一个子任务刚做完的那一刻,"已完成什么、结论是什么"最容易说清楚;压在工具调用和结果之间是最糟的时机,会把成对的调用与返回拆散。

三步纪律:先 pin,再摘要,最后留审计

第一步是整套机制的关键:关键信息用代码保留,不要交给摘要器。摘要器是个模型,它会漏、会改写、会把"用户明确拒绝了方案 B"写成"讨论了几个方案"。凡是丢了就无法恢复的东西,都不该经过模型这一道。

python
from langchain_core.messages import RemoveMessage, SystemMessage
from pydantic import BaseModel

PIN_KEYS = ("task", "constraints", "confirmed_slots", "citations", "todo", "open_tool_calls")

class Compaction(BaseModel):
    """摘要器的输出 schema。固定字段比自由文本可靠得多;
    negatives 是最容易被摘丢、丢了代价最大的一项"""
    done: list[str]                 # 已完成的子任务
    findings: list[str]             # 已得出的结论(带引用 ID)
    open_questions: list[str]       # 还没解决的问题
    negatives: list[str]            # 用户拒绝过的方案、已排除的可能性
    entities: dict[str, str]        # 关键实体 → 值(订单号、时间范围、金额口径)

async def compact(state: dict, *, keep_recent: int = 6) -> dict:
    pinned = {k: state[k] for k in PIN_KEYS if state.get(k)}       # 第一步:纯代码,不过模型
    old, recent = state["messages"][:-keep_recent], state["messages"][-keep_recent:]
    if len(old) < 4:
        return {}                                                  # 太短不值得压
    summary = await summarizer.with_structured_output(Compaction).ainvoke(render(old))
    audit_id = await save_audit(thread_id=state["thread_id"], raw=old,   # 第三步
                               summary=summary, pinned=list(pinned))
    return {
        "messages": [RemoveMessage(id=m.id) for m in old] +        # 真正删掉,见 ./agent-langgraph
                    [SystemMessage(content=render_context(pinned, summary, audit_id))],
        "compactions": [audit_id],
    }

压什么,不压什么

不压(pin 住或外置)
已完成子任务的中间过程、工具调用的原始返回原始任务描述与硬约束
被否决的中间尝试的细节已确认的槽位(地址、时间范围、金额口径)
重复出现的检索片段引用 ID 列表(丢了引用就无法溯源,见 引用与可溯源
闲聊、寒暄、确认性应答待办清单与当前进度
——未闭合的工具调用(压掉会让消息成对关系断裂,多数模型会直接报错)

踩坑: 摘要节点自己也吃 token,而且恰好在窗口最紧的时候吃。给它配一个独立的小模型 + 分段摘要(先分块各自摘要再合并),否则会出现"为了压缩而爆窗"这种很荒诞的失败。

无痕压缩等于篡改历史

审计记录不是锦上添花。线上来一个"Agent 忘了我说过要开专票"的投诉,没有审计你根本无法区分三种情况:用户其实没说过、摘要器把它丢了、pin 的字段列表漏了它。三种情况的修法完全不同。

sql
CREATE TABLE agent_compaction (
    compaction_id  TEXT PRIMARY KEY,
    thread_id      TEXT NOT NULL,
    raw_ref        TEXT NOT NULL,       -- 原文在对象存储的 key,别塞进这张表
    summary        JSONB NOT NULL,      -- Compaction 的结构化结果
    pinned_keys    TEXT[] NOT NULL,     -- 当时 pin 了哪些字段
    tokens_before  INT NOT NULL,
    tokens_after   INT NOT NULL,        -- 压缩比 = after / before,要监控
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON agent_compaction (thread_id, created_at DESC);

tokens_before / tokens_after 值得单独看一眼分布:压缩比长期高于 0.6 说明摘要器太啰嗦,白付了一次调用;长期低于 0.1 说明压得太狠,大概率在丢信息。

摘要的摘要会逐代失真

一条跑了很久的轨迹如果反复压缩,第 5 代摘要和原文之间已经隔了五次模型改写。缓解办法是始终从 pin + 上一代摘要 + 新增原文重新生成,而不是"摘要上一份摘要",并且把代数记进审计。代数超过阈值(比如 5)就该考虑换成任务账本 + 外置化,见后面两节。


对话历史裁剪:四种策略的失败模式

策略做法成本失败模式
全保留什么都不删线性上涨直到爆窗长会话必炸;早期无关内容一直在付费
滑动窗口只留最近 N 轮恒定截断失忆:第 1 轮的"只寄公司地址"在第 20 轮消失了
摘要 + 近 N 轮(推荐)老的压成结构化摘要,近 N 轮保留原文恒定 + 摘要调用摘要漏掉否定项和已确认槽位;反复压缩逐代失真
向量召回相关历史历史入库,按当前问题召回 Top-K恒定 + 一次检索召回不到等于没有;跨话题跳转时召回错轮次,产生上下文错位

生产推荐: 三者叠加而不是三选一。摘要 + 近 N 轮打底;槽位、否定项、引用 ID 用 pin 机械保留,不依赖摘要;向量召回只作为"用户忽然提起很久以前那件事"的补充,且召回结果要标明来自第几轮。


记忆分层:短期、会话、长期

三层的存储、生命周期和写入方式完全不同,混在一起谈是很多设计事故的起点。

存在哪生命周期写入方式写错的代价
短期(本轮上下文)提示词里一次模型调用装配层每次重新组装本轮答错,下轮就没了
会话(thread)Checkpointer一次会话,TTL 数小时到一天每个节点后自动落库过期即丢,可接受
长期(跨会话)PostgreSQL + 向量库长期保留,用户可删显式抽取、过闸、可审计长期污染之后所有会话

短期这一层就是前面几节讲的账本、整形、压缩。会话这一层的机制在 LangGraph CheckpointMulti-Agent 会话记忆 里;本节只讲第三层,因为它是唯一一个"写错了会跨会话反复复用"的地方。

写入必须过闸:不是什么都记

python
from typing import Literal

class MemoryCandidate(BaseModel):
    kind: Literal["preference", "fact", "constraint", "lesson"]
    key: str                     # 归一化后的槽位名,用于 upsert 去重
    text: str
    subject: str                 # 归属:user:123 / tenant:9,没有归属不能写
    confidence: float
    source_turn: str             # 来源消息 id,出问题能追到原话
    ttl_days: int | None = None

STABLE = {"preference", "fact", "constraint", "lesson"}

def admit(c: MemoryCandidate) -> tuple[bool, str]:
    """长期记忆四道闸。宁少写不错写——漏记一条只是少一点体验,
    错记一条会在后面每一次会话里复现"""
    if c.kind not in STABLE:                 return False, "not_stable"     # 一次性信息不进
    if c.confidence < 0.8:                   return False, "low_confidence" # 推测不进
    if contains_sensitive(c.text):           return False, "sensitive"      # 证件号、卡号、验证码
    if not c.subject.startswith(("user:", "tenant:")): return False, "no_subject"
    return True, "ok"

该记: 稳定偏好("只寄公司地址")、已确认且长期有效的事实(发票类型、常用收货地)、用户明确拒绝过的方案、历史问题的结论摘要。

不该记: 一次性验证码、完整证件号与卡号、临时优惠信息、模型的中间推理、未经确认的推测("用户可能是学生")。最后一类最容易混进来,因为它读起来和事实一模一样。

召回必须防污染

风险表现防法
错误记忆长期复用一条写错的"该用户是 VIP"影响后面所有会话每条带 source_turn 可追溯 + 用户可见可删 + 记命中次数与负反馈
记忆冲突旧地址和新地址同时在库里,召回到哪条看运气(subject, kind, key) 做 upsert 而不是 append;召回只取最新
过期记忆去年的偏好当成今天的写入给 TTL,召回按时间衰减打分
记忆挤占窗口一次召回 20 条把窗口占满硬上限 3-5 条,且只在意图相关时才召回
记忆被当权威模型拿记忆里的金额做承诺注入时标来源和时间:[用户偏好·2026-03 记录],并在系统提示写明"记忆是线索不是权威,涉及金额、权限、时效必须实时校验"

最后一行和 Agent 生产可靠性 里"降级数据不得作为承诺依据"是同一条规则的两个应用面:凡是非实时、非权威的内容进上下文,都必须自带来源标注和使用限制。


文件工作区:把窗口当 RAM 用

窗口里只留指针,正文放文件或数据库,模型需要时现读。这个模式和操作系统的虚拟内存几乎一一对应,用这个类比能一次讲清一整套机制。

操作系统Agent 里对应什么性质
RAM上下文窗口快、贵、稀缺,每一步都要重新付费
磁盘文件 / 数据库 / 对象存储慢、便宜、几乎无限,必须显式读取
指针 / 虚拟地址ref_id、文件路径、doc_id窗口里只放它
缺页中断模型主动调 read(ref, start, lines)用到才调进窗口
swap压缩换出去要付代价,换回来不保真
进程隔离子代理独立窗口,只通过返回值通信
懒加载 + 索引渐进披露:先给目录,再给正文常驻只放索引
OOM Killer硬窗口断言 + fail fast越界显式失败,不静默截断

落地成两个工具就够了:一个 write_workspace 把大块内容存下来并返回引用,一个 read_workspace 按范围读回。模型看到的永远只是引用和摘要:

python
# context/workspace.py
from pathlib import Path
import hashlib, json

WS = Path("./workspace")


def write_workspace(name: str, content: str) -> dict:
    """把大块内容落盘,只把引用和摘要返回给模型。"""
    WS.mkdir(exist_ok=True)
    ref = f"{name}-{hashlib.sha256(content.encode()).hexdigest()[:8]}"
    path = WS / f"{ref}.txt"
    path.write_text(content, encoding="utf-8")
    lines = content.count("\n") + 1
    return {                                    # 这个 dict 才进窗口,通常几十个 token
        "ref": ref, "lines": lines, "chars": len(content),
        "head": content[:200],                  # 给模型一点线索判断要不要读
    }


def read_workspace(ref: str, start: int = 0, limit: int = 80) -> dict:
    """按行区间读回,永不整篇灌进窗口。"""
    path = WS / f"{ref}.txt"
    if not path.exists():
        return {"status": "not_found", "ref": ref}      # 结构化失败,不是空字符串
    all_lines = path.read_text(encoding="utf-8").splitlines()
    window = all_lines[start:start + limit]
    return {
        "status": "ok", "ref": ref,
        "range": [start, start + len(window)], "total_lines": len(all_lines),
        "content": "\n".join(window),
        "truncated": start + limit < len(all_lines),    # 显式告诉模型后面还有
    }

附带好处:崩溃可续跑。 中间产物在磁盘上而不只在窗口里,进程被杀之后重启只需要重新读引用,已经做完的抓取和解析不用重做。这一点和断点续跑是同一套设计的两面——把状态放到进程外,窗口和进程都变成可丢弃的。

踩坑: 工作区必须按会话或线程隔离目录,否则多租户下 ref 可以被猜到,等于给了一个跨会话读文件的口子。引用里带上 session_id 前缀并在 read_workspace 里校验归属,参见Prompt 注入攻防里"动作参数来源校验"那一节。


子代理隔离:把脏活外包出去

有些活天生占窗口:读 30 个网页、扫一个目录下上百个文件、把一份长报告逐段核对。这些活的过程很脏(几十万 token 的原始材料),结论很干净(一段几百字的总结)。子代理的意义就是让过程发生在另一个窗口里,主上下文只收结论。

效果最直观的指标是主窗峰值占用:同样的任务,不外包时主窗峰值可能到几千甚至上万 token 并触发多次压缩;外包后主窗只涨几百 token,压缩机制根本不用启动。压缩是有损的,能不压比压得好更值。

什么时候该外包,什么时候不该

判断该外包不该外包
过程材料量远大于结论量(读多写少)过程和结论差不多大
上下文依赖任务能用一段自包含的描述讲清需要主 Agent 的完整对话历史才能判断
交互需求一次性交付,不需要中途来回确认需要边做边跟用户澄清
结果形态可结构化(清单、摘要、字段)需要保留全部细节供后续引用
延迟容忍可以并行发多个子代理链路已经很紧张,多一跳受不了

踩坑(最容易犯的一个): 子代理拿不到主上下文。你以为它知道"用户刚才说只看 2025 年之后的",它并不知道。所以派发时必须把约束显式写进子任务描述里,而不是指望它能猜到。

子代理失败必须返回结构化失败,不能返回空结论

这是个真实会造成错误答案的坑:子代理去查三个信源,两个超时,它返回"未找到相关信息"。主 Agent 收到这句话,合理地推断"这个信息不存在",然后写进最终答案。"没能看到"被当成了"不存在"。

python
# 反例:主 Agent 无法区分"查过没有"和"没查到"
return {"summary": "未找到相关信息"}

# 正例:把覆盖度和失败原因摊开
return {
    "status": "partial",                          # ok / partial / failed
    "summary": "在可访问的 1 个信源中未发现相关条款",
    "sources_attempted": 3,
    "sources_succeeded": 1,
    "failures": [
        {"source": "gov-portal", "error": "timeout", "retried": 2},
        {"source": "policy-db", "error": "http_503", "retried": 1},
    ],
    "confidence": "low",                          # 主 Agent 据此决定要不要重试或转人工
}

主 Agent 拿到 status: partial 就该做三件事之一:换信源重试、把不确定性写进答案、或者交给人。这和引用溯源、拒答与降级里"证据不足必须拒答"是同一条原则在子代理边界上的应用。

拓扑层面的分工(谁派发、怎么路由、怎么移交)在Multi-Agent 编排里已经讲过,这里只补上下文视角:子代理首先是一个上下文隔离装置,其次才是一个分工装置。


渐进披露:指令也要分层

系统提示词和工具说明是每一轮都要重新付费的常驻开销。工具从 5 个涨到 30 个,光工具 schema 就能吃掉几千 token,而单轮真正用得上的往往只有一两个。渐进披露的做法是把指令拆成三层:

层级内容是否常驻典型体积
核心角色、硬性红线、输出格式约定常驻几百 token
索引能力清单:每项一行名字 + 一句话用途常驻每项 10 到 20 token
正文完整参数说明、示例、边界条件按需加载每项数百 token
python
# context/disclosure.py
from pathlib import Path

SKILLS = {
    "refund_policy": {
        "brief": "退款规则与时限判断",                    # 进索引
        "detail_path": "skills/refund_policy.md",        # 用到才读
    },
    "invoice_rules": {
        "brief": "发票开具与红冲流程",
        "detail_path": "skills/invoice_rules.md",
    },
}


def build_index() -> str:
    """常驻部分:只有名字和一句话。"""
    return "\n".join(f"- {k}: {v['brief']}" for k, v in SKILLS.items())


def load_skill(name: str) -> dict:
    """模型判断需要时才调,正文这一轮才进窗口。"""
    meta = SKILLS.get(name)
    if not meta:
        return {"status": "unknown_skill", "available": list(SKILLS)}
    return {"status": "ok", "name": name,
            "content": Path(meta["detail_path"]).read_text(encoding="utf-8")}

同一套思路适用于工具(按意图裁剪可用工具集,见Tool Calling)、领域知识(先给目录再给条款)、以及长文档(先给大纲再给章节)。

生产推荐: 先量再拆。用前面那套上下文账本看清系统提示和工具 schema 到底占了多少,占比不到一成就别急着上这套——它引入了"模型可能不去加载它需要的那份说明"的新失败模式,收益不够时不划算。


长任务:把进度放到窗口外

任务跨越几十步甚至几次会话时,"我做到哪了"必须落在窗口外,否则一次压缩或一次崩溃就把进度丢了。一个任务账本包含三样东西:

组成内容谁写
TODO 树子任务列表、依赖、每项状态(待办 / 进行中 / 完成 / 失败)规划节点写,执行节点更新
产出索引每个已完成子任务的产物引用(工作区 ref执行节点写
增量简报相比上次的变化摘要,给人看也给下一轮的自己看汇总节点写

窗口里只放"当前子任务 + TODO 树的压缩视图(比如 12 项已完成 8 项,当前第 9 项)+ 上一次的简报",其余全在账本里。这样做还有个副作用:账本本身就是给人看的进度报告,不用额外做一套。

具体的持久化与恢复(任务注册表、孤儿回收、已完成节点不重做)在Agent 生产可靠性里,两者配合使用:账本管"做什么",Checkpointer 管"做到哪一步的状态"。


超支了先动哪一刀

真正上线后,"上下文又爆了"是个高频问题。按性价比排序处理,不要一上来就折腾压缩——压缩是最后一招,因为它是唯一有损的那一招。

顺序动作典型收益代价什么信号提示该动这刀
1工具返回值整形(截断、分页、只返引用)很大,工具结果常占大头几乎无损,需改工具实现账本显示工具桶占比过半
2对话历史裁剪(滑动窗口 + 摘要)中等,随轮次增长丢远期细节多轮长会话后期才爆
3渐进披露(工具与指令分层)中等,且是常驻节省多一次加载往返工具或系统提示桶偏大
4外置化到工作区(窗口只留指针)很大多一次读取往返单个材料就撑爆窗口
5子代理隔离(过程外包)极大,能降一个量级子代理拿不到主上下文某一段流程稳定吃掉大量材料
6压缩兜底有损,且要留审计以上都做完仍然逼近上限

一个反直觉的顺序

很多人第一反应是"上压缩"或"换更大窗口的模型"。这两个都是把问题往后推:压缩会丢信息,更大窗口会同时带来更高成本、更高延迟和中间信息丢失。先看账本,先砍工具返回值。 这一刀通常能解决一半以上的超支,而且无损。


面试高频问题

1. 上下文窗口更大了,上下文工程是不是就不需要了?

  • 不是。窗口变大解决的是"塞不进去",解决不了"塞进去了但用不好"。
  • 三个不随窗口增大而消失的问题:中间信息丢失(关键内容在长上下文中间时召回率下降)、噪声稀释(无关材料拉低信噪比)、成本与延迟随输入线性上涨。
  • 目标从"能不能装下"变成"每个 token 值不值得占位"——这是个经济问题,不是容量问题。
  • 加分:可以指出跨会话记忆和长任务进度天生在窗口之外,无论窗口多大都需要外置化。

2. 你怎么知道上下文里什么占了大头?

  • 按来源分桶计量:系统提示、工具返回、检索材料、对话历史、当前输入,各算 token 占比。
  • 用可注入的 tokenizer 而不是估算字符数,中英文混排下字符数和 token 数偏差很大。
  • 实测里工具返回值通常是最大的一桶,这也是为什么"整形工具返回值"是性价比最高的第一刀。
  • 别踩的坑:只在出问题时临时打日志。账本应该常驻,作为每次运行的体检项之一(见可观测性)。

3. 压缩摘要怎么保证不丢关键信息?

  • 分两类处理:关键信息(原始任务、硬约束、已确认的结论、待办清单)用机械 pin,不经过模型,摘要器再糟也丢不掉;其余交给模型摘要。
  • 压完必须留审计记录(压了哪些轮次、压缩前后 token、摘要全文),否则出问题无法复盘——无痕压缩等于篡改历史。
  • 摘要后做一次校验:pin 的字段是否还在、待办数量是否一致。
  • 加分:说清"能不压比压得好更值",所以压缩之前应该先试外置化和子代理隔离。

4. 子代理和 Multi-Agent 是一回事吗?

  • 有重叠但出发点不同。Multi-Agent 关注的是分工与路由(谁擅长什么、怎么移交);子代理在上下文工程语境下关注的是窗口隔离(脏活的过程不要污染主窗口)。
  • 一个子代理可以只是"同一个模型、同一套工具,但开一个干净窗口去读 30 个网页",这里没有任何分工含义。
  • 判断要不要外包看两点:过程材料是否远大于结论、任务能否用自包含的描述讲清。
  • 别踩的坑:子代理失败返回空结论,主 Agent 会把"没能看到"当成"不存在"。必须返回结构化失败(尝试了几个源、成功几个、失败原因)。

5. 跨会话的长期记忆怎么防止记错的东西一直被复用?

  • 写入侧设闸:只记稳定的偏好和事实,不记临时状态和推测;带来源和时间戳;同类记忆做去重与更新而不是无限追加。
  • 召回侧防污染:召回结果标记为"历史记忆"而非"事实",与本轮检索到的证据分开呈现,冲突时以当前证据为准。
  • 记忆要可撤销:能按 ID 删除、能按时间失效,出现错误记忆时不用清库。
  • 加分:提一句这和 RAG 的失效数据过滤是同一个问题(见RAG 入库链路),只是数据源是自己写的。

6. 长任务跑到一半进程被杀,怎么接着跑?

  • 进度落在窗口和进程之外:TODO 树 + 产出索引存库或存文件,节点状态交给 Checkpointer。
  • 恢复时按账本找到第一个未完成子任务,已完成的产物用引用读回,不重做。
  • 需要孤儿回收:扫描长时间处于"进行中"的任务并重新调度,见Agent 生产可靠性
  • 别踩的坑:把进度只写在 State 里而 State 又被压缩过——压缩必须 pin 住待办清单。

7. 面试官问"你做过上下文工程吗",该怎么答才不空?

  • 不要罗列名词。给一个具体的数字对比:某条链路原来主窗峰值多少、动了哪一刀、降到多少、答案质量有没有变差。
  • 讲清你做的取舍:为什么先整形工具返回而不是先上压缩。
  • 说明你怎么验证有效:开关矩阵跑同一批任务,对比 token 占用和答对率,确认没有为了省 token 牺牲质量(方法见评测)。
  • 加分:坦白哪一招你试过但收益不明显并说出原因——这比声称每招都有效可信得多。