Skip to content
Charles Shao
Go back

对抗失忆与噪声:上下文裁剪与记忆挂载

–views

剥离了框架的黑盒后,上一篇我们通过明确 schema 定义,让模型在路由分发与参数回填上表现得相当稳定。然而随着并发调度的推进,最棘手的问题立刻浮出水面:搜索酒店的返回报文、订房接口的冗长回执,在每一轮对话中都被原封不动地透传给大模型。

这里必须先认清一个残酷的现实:脱离了外部状态机流转,大模型在每一轮 API 调用中都是绝对失忆的。两次 HTTP 请求之间,它根本不具备“我还记得刚才搜过西安”这种上下文连贯性,能感知到的输入边界仅限于当前请求的两个核心参数——一个是不断膨胀的 messages 上下文数组,另一个是固定的 tools(即 schema 定义)。

messages 本质上是一个仅支持 Append-Only 的执行日志。标准调度循环中,为了让模型基于已有进展继续推理,必须把上游工具的返回不断追加进数组;否则上下文一旦断层,模型就会因幻觉或状态丢失,把刚搜过的酒店再无意义地重搜一遍。但这种粗暴的回灌代价极高:messages 只增不减,每一次裸调 API 都会把全量历史整段重发,交互轮数稍多,累计 Token 就按 N²/2 的二次方级数飙升。更致命的是,模型底层存在固定的上下文窗口硬限制(Context Window),一旦触碰这条红线,系统不仅会迟钝,更会触发上下文截断,甚至直接抛出 HTTP 400 把请求彻底熔断。

为了应对这种无休止的上下文膨胀,工程落地时往往会陷入两难:

因此,这篇实战核心只解决两个工程痛点:其一,将过期的 tool 历史返回执行上下文压缩(保留消息结构,仅把 content 替换为摘要);其二,将全局执行目标、可用资源 ID 列表以及试错日志统一打平,下沉到脱离调度循环的独立记忆结构中,并在每次请求前回灌进 system prompt。核心调度 Loop 无需伤筋动骨,只需在每次裸调 API 的前置拦截器里加一层状态同步即可。

核心架构原则:透传给模型的 messages 链路中,针对旧的工具返回,只保留压缩后的摘要字典;而全局业务目标、可用 ID 集合以及试错上下文,必须统一收敛到独立的记忆结构中,并在每一轮流转时动态渲染进 system。

为防止概念混淆,全文涉及的系统名词统一遵循以下严格定义:

术语架构语义
messages透传给大模型 API 的全量多轮对话数组
systemmessages 中 role 为 system 的初始配置节点,通常固定在 messages[0]
规则system 中具备静态约束特性的部分,即全局 SYSTEM_PROMPT
记忆独立于调度循环之外的状态字典,每轮流转时动态合并至 system
上下文压缩拒绝破坏消息链条,仅将历史 tool 节点的 content 替换为高密度摘要
协议配对严格遵守 OpenAPI 规范,每一条 assistant.tool_calls 必须存在映射相同 tool_call_id 的下游 tool 返回
终答当路由决策不生成 tool_calls 时,assistant 输出的纯文本 content
run一个业务目标从进入调度引擎到最终出口的完整生命周期
续跑携带已持久化或缓存的 messages 游标与记忆状态,重新挂载进入调度循环
回灌不可信坚决禁止将工具返回的 content 作为大模型的直接指令;合并入 messages 前,必须通过字段白名单清洗出下一步必需的有效参数

Table of contents

Open Table of contents

一、system 的本质:基于角色边界的状态容器

messages 协议规范中,每一条记录都必须声明其 role。手搓 Agent 场景下,最核心的四种角色分工如下:

role写入主体负载内容 (Payload)
system调度引擎层全局规则约束 + 动态记忆状态
user终端用户 / 业务中台当前 Run 的核心业务指令
assistantLLM 推理引擎决策输出:面向用户的终答,或“触发特定工具调用”的路由指令
tool工具执行层上游服务的结构化回执

相比于套壳调用高层框架,手搓架构要求我们深刻理解 system 的定位:它本身也是 messages 数组内的一等公民,绝非 client.create 接口之外游离的全局参数。它与 user、assistant、tool 的核心边界在于职责切分——system 在全局层面确立“你的角色边界是什么、当前系统状态处于什么位面”,而后续三种角色则以流水账的形式记录“当前任务是如何一步步状态机流转的”。将不可变规则与动态记忆统一收敛在 system 中,能最大限度利用模型底层对 system 指令的高权重注意力机制,迫使其被视为绝对约束。

二、三轮订房博弈:messages 的膨胀之谜

假定当前核心目标仍是“在西安 10 月 1 日预订一间 suite,住两晚”。状态机启动初始,上下文中仅包含两个节点:挂载了规则与空记忆的 system,以及 user 节点。

SYSTEM_PROMPT = (
    "你是专业的酒店预订引擎。房价计算规则和可用 hotel_id 必须严格源自工具回执或本地记忆池。"
    "仅在用户明确下达预订指令时,方可路由至 book_hotel 工具。"
)

messages = [
    {
        "role": "system",
        "content": SYSTEM_PROMPT + "\n\n当前记忆状态:\n"
        + '{"goal": "西安 10-01 订一间 suite,住两晚", "hotel_ids": [], "rejected_trials": [], "booked_record": null}',
    },
    {"role": "user", "content": "西安 10 月 1 日订一间 suite,住两晚"},
]

第 1 轮调度: 推理引擎解析上述初始状态。由于本地记忆缺乏可用的 hotel_id,系统触发路由逻辑,发起 search_hotels 工具调用。调度层阻塞等待接口响应后,将返回的 JSON 回执无脑 append 至 messages 尾部,system 节点保持静止。

第 2 轮调度: 同一份 system 依然锚定在 messages[0]。由于后置位追加了全量搜索结果,模型从中解析出 HT-002,随即将状态机推进至 book_hotel 调用分支。

第 3 轮调度: 危机显现。system 仍在首位,身后却拖拽着冗长的历史包袱——初始目标、三家酒店的数百行全量 JSON 返回、以及长篇大论的订房回执。尽管模型此刻只需简单反馈一句“预订成功”,但第 1 轮产生的酒店图文介绍与无关字段,依然作为死数据在上下文中强行占位。

若不实施任何上下文压缩,第 3 轮透传给大模型的整份 messages 结构将如下所示。注意首条是 system;而 c1、c2 则是强绑定的协议配对 ID,一旦在清洗时误删对应的 tool 消息,整个调用链将当场崩溃。

[
    {
        "role": "system",
        "content": SYSTEM_PROMPT + "\n\n当前记忆状态:\n"
        + '{"goal": "...", "hotel_ids": ["HT-001", "HT-002", "HT-003"], "rejected_trials": [], "booked_record": {...}}',
    },
    {"role": "user", "content": "西安 10 月 1 日订一间 suite,住两晚"},
    {"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "search_hotels", ...}}]},
    {"role": "tool", "tool_call_id": "c1", "content": "<三家酒店的超大体积 JSON,含多语言介绍、高清图床外链>"},
    {"role": "assistant", "tool_calls": [{"id": "c2", "function": {"name": "book_hotel", ...}}]},
    {"role": "tool", "tool_call_id": "c2", "content": "<长篇订房回执>"},
]

仔细剖析这轮调用,与初始态相比系统发生了两处状态迁移:其一,system 中的记忆字典完成了状态流转,从最初的空列表被打平并回灌了搜到的 hotel_ids(预订成功后还会追加 booked_record);其二,数组尾部急剧膨胀,堆叠了四条沉重的 assistant/tool 交互历史。

原生 client.chat.completions.create API 并不存在独立的 system 形参。我们将规则与记忆统一收拢在 messages[0] 内,与后续轮次的对话数据一并全量透传;与此同时,每一轮请求都必须原样挂载庞大的 tools Schema 定义:

resp = client.chat.completions.create(
    model=MODEL,
    messages=messages,   # messages[0] 固定为 system,尾部数组随轮数指数级膨胀
    tools=TOOL_SCHEMAS,  # 全量 Schema 定义,每轮强刷
    tool_choice="auto",
)

既然 system 和 tools 都会在每轮请求中产生固定的 Token 开销,当规则复杂度或 Schema 规模上升时,基础带宽占用固然水涨船高。但真正击穿请求链路的,永远是 system 身后那无底洞般的 assistant 与 tool 多轮交互冗余。

三、清洗与裁剪:在入栈前斩断无效数据

应对上下文膨胀,最先开刀的重灾区无疑是工具回执。一个标准的后台 RPC 接口动辄返回几十个业务字段,但推理引擎在计算下一步状态转移时,往往只需要其中三四个核心 Key。多出来的营销文案、图片链接等字段,不仅在每一轮轮转中白白消耗宝贵的 Context Window,更极易引发大模型幻觉,使其把某个评论 ID 误认为目标 hotel_id。

字段裁剪的决策权绝不能交给模型去自回归,而必须在手搓工具代码时,由架构师前置拷问一个核心逻辑:当前的字段,在进入下一轮工具调用或终答生成时,是否具备上下文价值? 酒店预订链路中,一旦搜索完毕,模型仅需要两项核心能力——构造并填充 book_hotel 的出参,或向用户汇报最终的房价结算清单。以此为准绳,可建立起严苛的白名单过滤机制:

返回字段状态机流转依赖评估处理策略
idbook_hotel 必填的上游依赖参数保留
name组装最终用户终答时的关键主语保留
room_types / price_per_night房型抉择、总价核算与枚举值校验保留
blurb(介绍)不参与 book_hotel 传参,对状态机流转毫无影响剔除
photos、设施列表、评分长文同上,纯粹的视觉展示冗余剔除
全栈 Error Trace模型不具备源码级修复能力,仅需知晓“因何失败、合法枚举是什么”压缩为单行短语

判决依据必须紧扣下一个工具的 Schema 契约,以及终答生成时的关键事实依赖。凡未在目标 Schema 中声明的键值,默认拦截,严禁流入 messages 栈;至于终答必须展示的业务数据(如酒店全称、最终总价),即便并非下级接口的硬性入参,也需破例放行;其余两边不靠的字段,一律无情裁剪。

更关键的底线在于“回灌不可信”原则:任何来自外部工具的描述、评论甚至非结构化长文本,都潜藏 Prompt 注入风险,绝不能作为高优先级执行指令透传给模型。因此字段裁剪不仅是为了压榨 Token 空间,更是为了在物理层面屏蔽干扰噪音,防止把大段带有歧义的外部原文毫无防备地暴露在 messages 中。

于是,在将 HTTP Response 推入 messages 栈之前,必须编写强类型的白名单清洗器:

raw_response = {
    "id": "HT-002",
    "name": "钟楼索菲特",
    "room_types": ["standard", "deluxe", "suite"],
    "price_per_night": {"suite": 400},
    "blurb": "邻近地铁口,适合商务出行。" * 40,
    "photos": ["https://..."],
}

# 严格按需回灌,拒绝黑盒透传
return {
    "id": raw_response["id"],
    "name": raw_response["name"],
    "room_types": raw_response["room_types"],
    "price_per_night": raw_response["price_per_night"],
}

工程落地时,若对某个字段的去留犹豫不决,可通过分析历史执行轨迹来兜底:一旦系统监控显示某字段在后续所有 tool_calls 参数中均未被引用,同时也未被提取至全局记忆,下一次迭代即可放心剥离。异常流转同理:当同一类错误被触发并重试三次后,模型需要的仅仅是一条“豪华套房型不匹配”的结论,而非三份堆砌着一模一样堆栈的 Error Log,直接将这层结论抽象后落盘到记忆体系即可。

即便完成了字段的极限裁剪,历史轮次的 tool 节点依然顽固地存在于上下文中。对话推进到第 3 轮时,第 1 轮的精简搜索结果依然占据着宝贵版面。此时不少缺乏经验的开发者会忍不住用切片操作将其一刀切掉——一旦这么干,下游 API 立刻赏你一个冷酷无情的 HTTP 400 Bad Request。

四、上下文压缩:捍卫协议配对,仅置换 Content

若粗暴地从上下文中剥离掉陈旧的 tool 消息节点,下一轮请求大概率会直接以 400 报错阻断。原因在于底层 API 服务端严格执行基于 ID 的协议配对校验:一旦 assistant 节点声明了自身曾调用过 search_hotels(关联 id=c1),其在 messages 中的后续位面必须存在一条完美对应的 tool_call_id=c1 节点。哪怕只缺失其中一条配对链,服务端也会拒绝承认这份上下文的合法性。

三种架构演进对比。不压缩等于放任废话堆积:陈旧的 tool 节点依然挂载着多源 JSON 回执,每轮整体重发,导致 Token 消耗按二次方级数恶性膨胀。粗暴截断 tool 导致失忆加 400 熔断:assistant 节点中 tool_calls c1 残存,但配对的 tool 返回被削,服务端直接报 400,同时核心目标与试错记录一并灰飞烟灭。上下文压缩则实现了状态保全:assistant 的 tool_calls c1 维持不变,将映射的 tool 回执 content 平滑置换为 _compacted 高密度摘要,并抽取 hotel_ids 结论回写至 system 的记忆池。

因此最优雅的解法,是捍卫原有消息实体,仅对其负载的 content 实施定点手术:

# 压缩前:原始冗余状态
{"role": "tool", "tool_call_id": "c1", "content": "{...三家酒店的全量节点,含冗余描述...}"}

# 上下文压缩后:协议配对完好无损,正文被优雅地置换为高密度摘要
{"role": "tool", "tool_call_id": "c1", "content": "{\"_compacted\": true, \"summary\": \"命中 3 家目标酒店\", \"hotel_ids\": [\"HT-001\", \"HT-002\", \"HT-003\"]}"}

执行上下文压缩时,切忌使用僵硬的按条数截断逻辑,而应采用严格的“按轮流转”机制。这里定义的一轮,等于“一条挂载了 tool_calls 属性的 assistant 消息 + 其后方所有配对的 tool 回执消息”的集合。策略很清晰:仅保留最近一轮流转的完整原文上下文,更早的轮次则一律触发压缩机制,将其 content 强制重写。具体实现逻辑可参考文末的 compact_messages 算法。

为保证状态机不崩盘,以下节点被视为绝对禁区,必须保留原文:messages[0](承载全局规则与记忆的 system)、第一条 user(发起整个 Run 的原始目标)、最近一轮完整的交互来回,以及所有 assistant 生成的 tool_calls。compact_messages 的剪刀,只能挥向那些老旧的 role=tool 节点。而真正适合被压制成摘要的受害者,通常是那些已被消费过的搜索列表、陷入死循环的重复报错、或是纯粹用于页面渲染的冗余字段。

千万别试图从数组末尾倒数固定条数来截断数组。即便 messages[0] 稳如泰山地占据着 system 位,这种草率的切片依然极有可能从中间把一对强耦合的 assistant/tool 配对链硬生生撕裂:

# 极度危险的糊代码反例:必然导致配对断裂
outgoing_payload = [messages[0]] + messages[-6:]

工程规范上,本地环境必须落盘全量无损的 messages 用于排障溯源,而透传给大模型 API 的,则永远是经过深度压缩的轻量级副本,且必须确保副本的首条记录仍为 system:

# 生成深拷贝并执行上下文压缩
outgoing_payload = compact_messages(messages)
# 断言担保:outgoing_payload[0]["role"] == "system"
resp = client.chat.completions.create(
    model=MODEL, messages=outgoing_payload, tools=TOOL_SCHEMAS, tool_choice="auto"
)

当上下文压缩落锤之后,庞大臃肿的完整 JSON 列表便彻底从 tool 节点中蒸发。在后续状态流转中,倘若业务仍需索取具体的 hotel_id,模型就只能完全仰仗那块外挂的本地记忆池了。

五、记忆下沉:游离于循环之外的状态打平与回灌

记忆绝对不是大模型原生自带的内建状态。下一轮流转中它之所以能奇迹般地调用这些历史结论,仅仅是因为你的框架在底层偷偷将其回灌到了 messages[0] 中。我们只需剥离出一个轻量级的字典结构,交由调度代码在捕获工具返回后伺机更新,严禁让大模型自作主张地往记忆池里糊一堆难以解析的自然语言文本。

memory_store = {
    "goal": "西安 10-01 订一间 suite,住两晚",
    "hotel_ids": [],
    "rejected_trials": [],
    "booked_record": None,
}

# 拦截 search_hotels 回执:如遇城市切换,直接实施暴力覆盖
memory_store["hotel_ids"] = [h["id"] for h in hits]

# 捕获 book_hotel 失败路由
memory_store["rejected_trials"].append("room_type 尝试过豪华套房枚举,触发校验失败")

记忆池必须保持极简,只允许写入高度浓缩的结论:宏观目标、可用 ID 清单、因参数校验失败而产生的废弃记录、以及最终落库成交的订单快照。坚决不能把工具返回的长篇原文塞进来,否则记忆字典就会立刻沦为第二座废话垃圾场,使得压缩收益瞬间归零。

发起每一轮 API 请求前,调度引擎必须利用最新的记忆状态对 messages[0] 进行重塑,随后再挂载上下文压缩管道:

def with_memory_injection(system_prompt, memory_state):
    return {
        "role": "system",
        "content": system_prompt + "\n\n当前记忆状态:\n" + json.dumps(memory_state, ensure_ascii=False),
    }

# 强制重写 system 节点
messages[0] = with_memory_injection(SYSTEM_PROMPT, memory_store)
# 执行配对安全的上下文压缩
outgoing_payload = compact_messages(messages)

第 3 轮透传给远端 API 的报文副本将呈现出一种极度优雅的状态:头部的 system 节点已被最新记忆刷新,而尾部的陈旧搜索记录则被无情地坍缩为极简摘要。

[
    {
        "role": "system",
        "content": SYSTEM_PROMPT + "\n\n当前记忆状态:\n"
        + '{"goal": "...", "hotel_ids": ["HT-001", "HT-002", "HT-003"], "rejected_trials": [], "booked_record": {...}}',
    },
    {"role": "user", "content": "西安 10 月 1 日订一间 suite,住两晚"},
    {"role": "assistant", "tool_calls": [{"id": "c1", ...}]},
    # 陈旧 tool 节点已被坍缩为高密度摘要
    {"role": "tool", "tool_call_id": "c1", "content": "{\"_compacted\": true, \"summary\": \"命中 3 家目标酒店\", \"hotel_ids\": [\"HT-001\", \"HT-002\", \"HT-003\"]}"},
    {"role": "assistant", "tool_calls": [{"id": "c2", ...}]},
    {"role": "tool", "tool_call_id": "c2", "content": "<订房回执,触发最近一轮保留机制,维持原文原貌>"},
]

设计记忆体系时,有两条架构红线绝不能逾越,不能将它们与当前的记忆混为一谈:

六、重构调度引擎:记忆回灌 -> 上下文压缩 -> 请求透传

哪怕底层架构改得翻天覆地,调度引擎的出口依然被严格限制在三个:当不再生成 tool_calls 时抛出终答并终止,触达预设的最大并发轮数上限时强制熔断,亦或是累计 Token 开销击穿预算阈值时阻断。引入上下文压缩机制的目的,在于极速延缓累计 Token 的恶性膨胀,而非取代这套严密的出口管控逻辑。

for turn in range(1, max_turns + 1):
    # 步骤一:记忆回灌
    messages[0] = with_memory_injection(SYSTEM_PROMPT, memory_store)
    # 步骤二:执行配对安全的上下文压缩,留存最近 1 轮完整上下文
    outgoing_payload = compact_messages(messages, keep_rounds=1)

    # 步骤三:透传请求
    resp = client.chat.completions.create(
        model=MODEL, messages=outgoing_payload, tools=TOOL_SCHEMAS, tool_choice="auto"
    )

    used_tokens += resp.usage.total_tokens
    if used_tokens > token_budget:
        return f"系统警告:累计 Token 已超载(当前值:{used_tokens}),强制触发熔断。"

    # 后续逻辑:如无 tool_calls 则返回终答;存在则执行下游路由并追加 append

根据开销权重,我们梳理出一套由轻到重的兜底降级策略:

  1. 一旦下游工具响应回执,第一时间执行字段级别的白名单清洗(对应第三节)。
  2. 当交互轮数超出安全阈值时,触发上下文压缩,将过期的 tool content 强制置换为摘要(对应第四节),并同步刷新全局记忆池(对应第五节)。
  3. 倘若累计 Token 依然逼近红线,启动极端截断:仅保留 system 头节点、初始 user 目标,以及最后一轮完整的状态机来回。
  4. 如果以上手段皆用尽依然超载,无需犹豫,立刻触发熔断机制。

坊间也流传一种偏门做法:通过额外消耗一次大模型 API 调用,让其将陈旧的 messages 自行归纳为一段话。但这不仅带来昂贵的额外请求开销,更会引入极高的大模型幻觉风险与关键信息遗漏。相比之下,基于明确规则与结构化压缩的代码兜底,才是最稳妥的架构选择。

七、故障排查指南:界定幻觉、废话与失忆

当状态机运转异常、牛头不对马嘴时,架构师的第一反应必须是精准定性:

异常表象故障定性修复抓手
流转轮数极少,但 Token 消耗瞬间见顶废话堆积审查工具返回字段清洗逻辑;其次盘查 tools Schema 与 system 是否过度臃肿
交互进入后半段,却仍在反复拉取并咀嚼同一份历史搜索 JSON废话堆积检查 compact_messages 是否生效,强制压缩老旧 tool content
经历压缩后,模型开始胡编乱造 hotel_id、彻底遗忘初始诉求上下文失忆排查记忆池是否成功回写至 system,或检查最初的 user 节点是否在压缩中被误杀
同一个参数校验失败被死循环重试了三次以上,且入参毫无变化上下文失忆未正确捕获失败状态,需将异常回执明确写入 memory["rejected_trials"] 字典
接口无情抛出 HTTP 400,提示找不到对应的 tool_call 引用协议配对断裂致命错误:误删了 assistant 节点,或是采用了粗暴的按条数切片,导致完整轮次被撕裂
单次流转耗时极短,但整个 Run 的总体响应时间呈现抛物线级恶化废话雪球效应审视每轮全量重发的数据包体积,通常是冗余字段未清洗干净

完整源码参考

整体调度循环骨架与之前保持一致,核心的架构升级在于引入了独立的 Memory 状态管理以及 compact_messages 压缩管道。完整的手搓代码可在此处查阅:agent-in-action/part1-handmade-agent/03-agent-context-memory/agent.py。样例中我们故意让搜索接口返回一段冗长的废话介绍,正是为了直观对比上下文压缩前后的硬核差异。

实际 Debug 跑批时,建议重点观测 raw_chars(本地持久化的全量 messages 轨迹)和 sent_chars(经过压缩管道后实际透传给大模型的 payload 副本)。老旧的 tool 节点在经历无情压缩后,其结构中必定会带上 _compacted 标记。如果在测试目标中尝试连续追问两座城市,你会清晰地看到记忆池里的 hotel_ids 被新数据精准覆盖的全过程。

架构演进前瞻

经过本轮重构,messages 具备了抗膨胀的压缩能力,关键结论也有了可靠的记忆池作为兜底。然而即便如此,当前的调度引擎依然只能在单轮流转中决定极其短视的下一步 tool_calls。应对真正的复杂多步任务时,模型依然不可避免地会出现跳步或重复路由。在下一篇架构演进中,我们将彻底把长期“计划”也下沉到记忆体系中,通过引入 set_plan 与 mark_step 这对原语,从底层重塑执行步骤的 status 状态机流转。

极客总结

大模型在每一轮并发流转中,能感知的世界仅限于本次透传的 messages 数组与静态 tools。对于陈旧的 tool 历史,绝不允许粗暴物理删除,只能通过置换 content 为高密度摘要来规避废话堆积;一旦违规删减,必将因协议配对断裂而惨遭 HTTP 400 熔断。与此同时,全局目标、可用资源 ID 列表以及宝贵的试错日志,必须全部抽离并下沉到脱离调度循环的外部记忆池,在每一轮请求前重新打平并回写至 system。

工程落地的黄金法则:优先执行工具返回字段的白名单清洗,其次实施严格按轮次流转的上下文压缩,不到万不得已,绝不触碰累计 Token 的极限截断逻辑。任何妄图按固定条数去强行切片 messages 的糊代码行为,最终都会将 assistant 与对应的 tool 配对链粗暴撕裂,引发系统灾难。


–views
Share this post on:

Previous Post
让 Agent 先规划再执行:把计划写进记忆
Next Post
工具 Schema 怎么写:模型眼里只有 JSON