上下文与记忆:Agent 为什么会在长任务中失去方向,以及如何设计状态管理
解释模型本轮输入与长期记录的区别,用可运行的字符预算包装器保护当前任务、完整工具交换和结构化计划状态。
Hex4C59 14 分钟阅读 更新于 2026-10-17
AI 与 Agent·原创·概念与设计
在前两篇文章里,我们把 Agent 的核心结构拆清楚了:它需要目标、上下文、决策能力、执行能力和反馈闭环;而 Tool Use 是让它真正能行动的关键接口。
但有一个问题一直没有正面回答:当任务变长、工具调用变多、对话轮次增加之后,Agent 为什么开始犯低级错误、忘记之前的结论、甚至原地打转?
本文面向会写简单代码、了解工具调用的读者,说明程序怎样保存任务进度,并在下一次调用模型前挑选需要的信息。读完后,你应能区分“存过的信息”和“模型这一轮实际看见的信息”。后面的机制模块可以直接运行并测试,使用共享异步模型接口,不依赖未定义的客户端。它实现请求预算和计划状态;长期存储属于另一项职责,见持久记忆实战。
先给结论 #
- 先检查模型这一轮实际收到了什么。 目标或证据可能没有进入请求,也可能混在大量无关内容里;这些原因需要与工具本身出错区分。
- Context Window(上下文窗口)是模型本轮能接收的信息范围,有容量上限。 信息放在哪里、是否与当前任务相关,也会影响模型能否利用它,不能只看窗口大小。
- 本文区分三类记忆:工作记忆(当前上下文)、情节记忆(历史执行记录)、语义记忆(可检索的知识库)。 按任务需要选择,不能只靠不断追加对话保存全部进展。
- 状态管理在每轮调用前选择并检查输入。 当前目标、必要状态和工具协议不能被随意切断;历史信息可以按明确规则裁剪。
- 状态应来自实际操作和验收。 保存了一个计划对象,不代表下一次模型请求已经包含它,也不代表被标为完成的步骤确实成功。
失忆是怎么发生的 #
先把失忆的机制讲清楚,后面的设计决策才有依据。
Context Window 的本质 #
在本文的 messages 调用方式中,应用每次都要准备本轮输入;模型不会因为上一轮处理过某条结果,就自动在下一轮获得它。LLM(Large Language Model,大语言模型)依赖当前提供的内容继续生成。
因此,应用若要让模型参考历史,就要把相关消息重新放进本轮输入。保存在数据库或日志中、却没有传给模型的记录,并不属于这一轮的上下文。
这意味着两件事:
第一,context window 是有上限的。 具体容量由所选模型和接口决定。当历史、工具结果、当前输入和工具定义一起超过可接受范围时,请求就可能失败。本例另外执行可复查的字符预算,不把某个窗口参数当成已经生效的长度检查。
第二,信息放进窗口,不等于模型一定能用好。 “Lost in the Middle”描述的是这类现象:某些实验中,放在长输入中部的信息比开头或结尾更难被模型利用。对状态管理来说,应避免把关键目标和结论埋在大量无关结果中;这不等于“越靠后的信息永远越重要”。
工具调用是上下文的主要消耗者 #
在一个典型的 Agent 执行流里,每次工具调用会往 messages 里追加至少两条消息:
[assistant] → tool_call: {name: "search_web", arguments: {...}}
[tool] → tool_result: {content: "...搜索结果,可能几百到几千 tokens..."}假设一个任务调用工具 20 次,每次结果平均 500 tokens,仅结果就合计 10,000 tokens。这里是按给定条件计算的示例,不是实际测量。再加上系统提示词、用户消息、模型的中间推理,context window 消耗得非常快。
更麻烦的是:旧工具结果的后续用途会变化。第 3 次搜索的结果,在第 15 次推理时可能完全不相关,但它仍然占据着宝贵的 context window 空间。
失忆的三种典型表现 #
1. 重复工具调用
Agent 忘记了几轮前已经查过某个信息,再次调用相同的工具,拿到同样的结果,形成循环。
2. 目标漂移
任务执行到一半,Agent 开始偏离原始目标,被某个工具结果里的细节带跑,忘记最初要做什么。
3. 前后矛盾
Agent 在第 5 轮得出了“方案 A 不可行”的结论,但在第 12 轮上下文被稀释后,又重新考虑方案 A,甚至给出相反的建议。
这些表现都可能与必要信息没有进入本轮输入有关,也可能来自工具结果错误或状态更新遗漏。先记录实际请求和操作结果,再缩小原因,不能只凭“失忆”这个名称判断。
记忆的三个层次 #
要解决失忆问题,先要搞清楚 Agent 需要什么类型的记忆。
借用认知科学里的分类,Agent 的记忆可以分三层:
工作记忆(Working Memory) #
对应 context window。这是应用在当前调用中直接提供的信息;外部记录只有被取回并放进请求,才成为本轮可用资料。
特点:容量有限,需要按每次请求重新组装。
工作记忆里应该放什么?只放“当前这一步决策需要的信息”,不是“完整的历史记录”。
情节记忆(Episodic Memory) #
记录 Agent 的历史执行轨迹:做了什么、用了哪些工具、拿到了什么结果、做出了哪些决策。
特点:体量大、需要外部存储、按需检索。
情节记忆不住在 context window 里,而是存在外部(数据库、文件、向量存储)。当 Agent 需要回顾历史时,通过检索把相关片段取回来放进工作记忆。
语义记忆(Semantic Memory) #
存储领域知识、用户偏好、任务相关的结构化信息。比如“这个用户偏好 Python”、“项目使用 PostgreSQL”、“API 的鉴权方式是 Bearer Token”。
特点:相对稳定、高密度、跨任务复用。
语义记忆通常以结构化格式存储,可以在任务开始时直接注入 context,也可以按需检索。
大多数 Agent 系统只实现了工作记忆 #
这里讨论的是一种设计问题:开发者只把对话历史追加到 messages,却没有单独维护可回查的执行记录和稳定知识。消息变长后,关键进度更难从中找出。
需要回顾历史、复用知识的长任务,可以让三层协同:程序从执行记录中取回相关步骤,从知识库取回稳定信息,再与当前进度一起放进工作记忆。短任务未必需要全部三层,关键是每类信息有明确去处。
状态管理的核心设计 #
有了记忆分层的框架,现在来看具体的设计模式。
模式一:任务状态对象(Task State) #
与其每次从长对话里推断进度,可以单独保存原目标、待办、已验收步骤和失败原因。配套 planning.py 用 PlanStep 保存计划内容,用 StepRecord 保存执行状态,再通过 PlanResult 返回整体状态、当前阶段、尝试次数和修订记录。
更新顺序很关键:开始操作时记为 running;先检查工具循环是否正常结束、是否包含失败工具结果,再执行应用的验收函数;通过后才记为 complete。超时、失败和限额不能只换成一句说明文字,再走同一个“标记完成”分支。
状态在程序里还不够。规划模块会在下一步的用户消息中传入原目标、完成标准、当前步骤和已验证摘要,再调用模型。这些信息同样要通过预算检查;无法完整装下时应返回失败,不能静默删掉目标。
模式二:上下文压缩(Context Compression) #
压缩可以指模型生成摘要,也可以指按明确规则移除旧信息。两者改变的内容不同:摘要可能漏掉数字、条件和错误原因;直接删除则让这部分证据完全不在当前请求中。需要回查的原始结果应另行保存,不应把输入裁剪误当成永久存储。
本例实现确定性的整组裁剪,不额外请求模型做摘要。它先移除旧用户轮次,再移除当前轮中较早的完整 assistant/tool 交换;保留 system、developer、当前用户任务,以及当前轮最新消息单元。必要信息仍超预算时,拒绝底层模型调用。
这里不用 raw_result.split() 估计 token:中文长字符串可能没有空格,工具定义和 JSON 格式也会占用输入。本例明确测量字符,指标是规范 JSON 中 messages 与 tools 两部分的 Unicode 码点数,不冒充模型的 token 数或 HTTP 请求字节数。
模式三:滑动窗口历史(Sliding Window History) #
下载机制示例 ZIP,解压后在该目录执行。需要 Python 3.11 或更新版本,模型使用本地固定回复,不需要凭据:
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python -c "import asyncio,json,context_budget; print(json.dumps(asyncio.run(context_budget.demo()),ensure_ascii=False,sort_keys=True))"
python -m unittest -v test_context_planningPowerShell 使用 .venv\Scripts\Activate.ps1 激活。仓库内工作目录为 examples/agents/mechanisms,设置 PYTHONPATH=../common;ZIP 根目录已包含共享运行时。
完整实现是 context_budget.py。fit_context 返回裁剪后的副本和测量报告,不修改原消息;BudgetedModel 在每次 await model.complete(...) 之前调用它,因此预算不是一个没有参与判断的配置字段。包装器只保留最近 64 份测量报告和总调用次数,完整执行轨迹需要另存,不在这个类里维护一个无限增长的归档列表。
"""Measured character budget for messages/tools; preserves protocol and current task.
No tokenizer or model-quality claim: units are Unicode code points in canonical JSON.
"""
from __future__ import annotations
from collections import deque
from copy import deepcopy
from dataclasses import dataclass
import json
from typing import Any
from agent_runtime import ScriptedModel, Tool, run_agent
class ContextBudgetError(ValueError):
"""A valid, required input cannot fit, or its tool protocol is malformed."""
def canonical(value: Any) -> str:
try:
return json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":"), allow_nan=False)
except (TypeError, ValueError) as exc:
raise ContextBudgetError("request must contain finite JSON data") from exc
def request_characters(messages: list[dict], tools: list[dict]) -> int:
return len(canonical({"messages": messages, "tools": tools}))
@dataclass(frozen=True)
class BudgetReport:
before: int
after: int
limit: int
dropped_messages: int
dropped_turns: int
dropped_exchanges: int
@dataclass(frozen=True)
class PreparedContext:
messages: list[dict]
report: BudgetReport
@dataclass(frozen=True)
class _Unit:
indices: tuple[int, ...]
role: str
exchange: bool = False
def _units(messages: list[dict]) -> list[_Unit]:
"""Validate complete exchanges, including null assistant content/multiple calls."""
units = []
index = 0
while index < len(messages):
message = messages[index]
if not isinstance(message, dict):
raise ContextBudgetError("each message must be an object")
role = message.get("role")
if not isinstance(role, str) or role not in {"system", "developer", "user", "assistant", "tool"}:
raise ContextBudgetError("unknown message role")
content = message.get("content")
if role == "assistant":
if content is not None and not isinstance(content, str):
raise ContextBudgetError("assistant content must be text or null")
elif not isinstance(content, str):
raise ContextBudgetError("message content must be text")
if role == "tool":
raise ContextBudgetError("orphan tool result")
if "tool_calls" in message and role != "assistant":
raise ContextBudgetError("tool_calls must belong to an assistant")
calls = message.get("tool_calls")
if calls:
if role != "assistant" or not isinstance(calls, list):
raise ContextBudgetError("tool calls must belong to an assistant")
ids = set()
for call in calls:
if (not isinstance(call, dict) or not isinstance(call.get("id"), str)
or not call["id"] or call["id"] in ids or call.get("type") != "function"
or not isinstance(call.get("function"), dict)
or not isinstance(call["function"].get("name"), str)
or not isinstance(call["function"].get("arguments"), str)):
raise ContextBudgetError("malformed or duplicate tool call")
ids.add(call["id"])
indices = [index]
cursor = index + 1
pending = set(ids)
while pending:
if cursor >= len(messages):
raise ContextBudgetError("tool exchange has missing results")
result = messages[cursor]
if (not isinstance(result, dict) or result.get("role") != "tool"
or not isinstance(result.get("tool_call_id"), str)
or result.get("tool_call_id") not in pending
or not isinstance(result.get("content"), str)):
raise ContextBudgetError("tool results must match each call exactly once")
pending.remove(result["tool_call_id"])
indices.append(cursor)
cursor += 1
units.append(_Unit(tuple(indices), role, True))
index = cursor
else:
if calls is not None and calls != []:
raise ContextBudgetError("tool_calls must be a list")
units.append(_Unit((index,), role))
index += 1
return units
def fit_context(messages: list[dict], tools: list[dict], *, max_characters: int = 16000,
required_task: str | None = None) -> PreparedContext:
"""Remove old whole user turns, then old complete exchanges in the active turn.
All system/developer messages, the latest user task, and the newest active
assistant/exchange are required. An unfit required input raises before delegation.
The input objects are never changed. This controls requests, not external archives.
"""
if type(max_characters) is not int or max_characters < 1:
raise ContextBudgetError("max_characters must be a positive integer")
if not isinstance(messages, list) or not messages or not isinstance(tools, list):
raise ContextBudgetError("messages and tools must be lists")
units = _units(messages)
user_units = [i for i, unit in enumerate(units) if unit.role == "user"]
if not user_units:
raise ContextBudgetError("a current user task is required")
current = user_units[-1]
current_text = messages[units[current].indices[0]]["content"]
if not current_text.strip() or (required_task is not None and current_text != required_task):
raise ContextBudgetError("latest user message must contain the required current task")
pinned = {i for i, unit in enumerate(units) if unit.role in {"system", "developer"}}
pinned.add(current)
active = [i for i in range(current + 1, len(units)) if i not in pinned]
if active:
pinned.add(active[-1])
# A candidate is one complete historical user turn or one active atomic unit.
candidates: list[tuple[list[int], bool]] = []
cursor = 0
for user_index in user_units:
if user_index >= current:
break
if cursor < user_index:
loose = [i for i in range(cursor, user_index) if i not in pinned]
if loose:
candidates.append((loose, False))
next_user = next(i for i in user_units if i > user_index)
turn = [i for i in range(user_index, next_user) if i not in pinned]
if turn:
candidates.append((turn, True))
cursor = next_user
if cursor < current:
loose = [i for i in range(cursor, current) if i not in pinned]
if loose:
candidates.append((loose, False))
for i in active:
if i not in pinned:
candidates.append(([i], False))
before = request_characters(messages, tools)
dropped: set[int] = set()
dropped_turns = dropped_exchanges = 0
def outgoing() -> list[dict]:
removed_indices = {j for i in dropped for j in units[i].indices}
result = [deepcopy(message) for j, message in enumerate(messages) if j not in removed_indices]
if dropped:
result.insert(0, {"role": "system", "content":
f"Context controller omitted {len(removed_indices)} historical messages as whole units. "
"Re-read evidence if needed; omission is not proof of completion."})
return result
prepared = outgoing()
for candidate, is_turn in candidates:
if request_characters(prepared, tools) <= max_characters:
break
fresh = [i for i in candidate if i not in dropped]
dropped.update(fresh)
dropped_turns += int(is_turn and bool(fresh))
dropped_exchanges += sum(units[i].exchange for i in fresh)
prepared = outgoing()
measured = request_characters(prepared, tools)
if measured > max_characters:
raise ContextBudgetError("required task, instructions, schemas or newest exchange exceed character budget")
report = BudgetReport(before, measured, max_characters,
sum(len(units[i].indices) for i in dropped),
dropped_turns, dropped_exchanges)
return PreparedContext(prepared, report)
class BudgetedModel:
"""Shared-runtime model protocol with pre-call validation and bounded audit data."""
def __init__(self, model, *, max_characters: int = 16000, required_task: str | None = None):
if type(max_characters) is not int or max_characters < 1:
raise ContextBudgetError("max_characters must be a positive integer")
self.model = model
self.max_characters = max_characters
self.required_task = required_task
self.reports: deque[BudgetReport] = deque(maxlen=64)
self.calls = 0
async def complete(self, messages: list[dict], tools: list[dict]) -> dict:
prepared = fit_context(messages, tools, max_characters=self.max_characters,
required_task=self.required_task)
self.reports.append(prepared.report)
self.calls += 1
return await self.model.complete(prepared.messages, deepcopy(tools))
def _call(names: list[str], group: int) -> dict:
return {"role": "assistant", "content": None, "tool_calls": [
{"id": f"call_{group}_{i}", "type": "function", "function": {
"name": "read_fixture", "arguments": canonical({"name": name})}}
for i, name in enumerate(names)]}
async def demo() -> dict:
"""Real validated loop/compaction with fixed responses; owns no external state."""
observed = []
def read_fixture(name):
observed.append(name)
return {"name": name, "synthetic_data": "资料" * 480}
tools = [Tool("read_fixture", "Return bounded synthetic fixture evidence.", {
"type": "object", "properties": {"name": {"type": "string", "maxLength": 20}},
"required": ["name"], "additionalProperties": False}, read_fixture)]
scripted = ScriptedModel([_call(["one", "two"], 1), _call(["three"], 2),
_call(["four"], 3), {"role": "assistant", "content": "fixture 完成"}])
task = "保留这个当前任务,读取四份合成资料。"
model = BudgetedModel(scripted, max_characters=3700, required_task=task)
result = await run_agent(model, "fixture:上下文预算演示。", task, tools, max_turns=6, timeout=2.0)
assert result.status == "complete", result.text
assert observed == ["one", "two", "three", "four"]
assert any(report.dropped_exchanges for report in model.reports)
for request, report in zip(scripted.requests, model.reports):
assert report.after == request_characters(**request) <= model.max_characters
assert any(m.get("role") == "user" and m.get("content") == task for m in request["messages"])
_units(request["messages"])
return {"ok": True, "mode": "fixture", "status": result.status,
"model_calls": model.calls, "tools_executed": observed,
"max_request_characters": max(report.after for report in model.reports),
"limit_characters": model.max_characters,
"dropped_exchanges": sum(report.dropped_exchanges for report in model.reports),
"current_task_preserved": True, "complete_tool_exchanges": True}一条 assistant 消息可能同时请求两个工具,随后就需要两条对应的 tool 结果。这个交换单元包含三条消息,不能只按“最近两条”留下两个结果;assistant 的 content 也可能是 None,不能用它是否可迭代来识别工具调用。
实现检查 tool_calls 数组与调用 ID:每次调用恰好有一个结果,整组保留或整组移除。孤立结果、缺少结果、重复 ID 或中间插入用户消息都会在调用模型之前被拒绝。当前任务是最后一条用户消息,也可以通过 required_task 再检查内容是否与调用者指定的任务完全相同。
下面是 2026-10-17 运行 context_budget.demo() 的实测返回值。固定模型给出调用选择,四次合成资料读取和每次请求前的裁剪检查都实际执行:
{"complete_tool_exchanges": true, "current_task_preserved": true, "dropped_exchanges": 2, "limit_characters": 3700, "max_request_characters": 3019, "mode": "fixture", "model_calls": 4, "ok": true, "status": "complete", "tools_executed": ["one", "two", "three", "four"]}此演示设定 3700 字符上限,实际最大输入为 3019 字符。默认包装器上限为 16000 字符,可由调用者显式选择;换成不同提示或工具说明,测量值也会变。新输入中的任务、工具定义或最新完整交换若装不下,会返回预算错误,底层模型不会被调用。
模式四:显式规划 + 检查点(Planning + Checkpoints) #
计划状态与上下文预算解决两件事:前者记录哪些目标经过验收,后者决定这一轮能否把必要内容完整传入模型。配套规划器也使用 BudgetedModel,因此规划、步骤执行和检查点输入都受到同一字符检查。
规划器在一个总异步截止时间内推进;每步另有等待、工具轮次和调用次数上限。检查点只能替换待执行步骤,不能更改原目标或把已完成证据覆盖掉。重规划次数与实际尝试步数在外层累计,不因新计划对象出现而清零。
完整规划源码和运行结果见 Planning:提前分解目标。这套状态转移比“按摘要文字标完成”多了一道实质判断:只有实际执行状态和验收都通过,才允许下游依赖该步。
一个完整的状态管理架构 #
把上面几个模式组合起来,一个完整的状态管理系统大致是这样的:
图 1:各类状态的职责关系。规划器提供目标和步骤,状态对象记录操作结果,上下文构建器决定哪些内容进入当前请求;外部事件和知识需要被检索回来才会进入工作记忆。
各层职责:
任务规划器:任务开始时生成结构化计划,中途在检查点评估是否需要调整。
任务状态对象:实时记录目标、当前焦点、已完成步骤、关键发现、已确定决策。每轮推理前注入 context,是 Agent 方向感的核心锚点。
上下文构建器:在每轮调用前组装任务状态、所需资料和近期历史,再检查消息及工具定义的总量。本例执行实测字符预算;换成 token 预算时需要适配所用模型的计数方法。
工作记忆(Messages):按本轮需要保留消息。本例按完整轮次和工具交换裁剪,原始轨迹如需归档,由调用者另行保存;移出请求不代表已经自动存入数据库。
情节记忆:存储完整的历史执行轨迹,支持关键词或向量检索,在需要回溯时按需取回相关片段。
语义记忆:稳定的结构化知识,任务开始时注入,或在特定触发条件下更新。
实际工程中的几个决策点 #
决策点 1:压缩 vs 截断 #
当 context 快满时,有两种选择:直接截断最老的消息,或者先压缩再保留。
应先判断哪些信息会改变下一步决定。具体数字、错误码和条件可能需要原文;assistant 的解释也不能假定都能从状态重建。本例只删除完整旧单元,并保留任务和最新交换;如果选择摘要,应另外验证摘要保留了哪些字段、原始证据放在哪里。
决策点 2:什么时候做检查点 #
不是每步都需要做检查点,过于频繁的规划重评估会增加延迟和成本。可以按自己的任务约定触发条件,例如:
- 完成一个预定义的“阶段性目标”
- 工具调用失败或返回了意外结果
- 模型的输出里出现了不确定性信号(“我不确定是否应该……”)
- 已完成的步骤数达到计划步骤数的 50%
决策点 3:语义记忆放多少进 context #
语义记忆是稳定的,但不是无限的。放太多会占用工作记忆空间,放太少又起不到作用。
一个可行策略:把语义记忆分成“核心知识”和“扩展知识”两部分。先挑选本轮必需的知识和状态,完整放入受保护的当前任务;扩展知识按需检索。“核心”只是业务选择,仍要接受总预算检查,不能据此无限追加输入。
决策点 4:任务状态对象应该多详细 #
任务状态对象的粒度需要平衡:太粗糙提供不了足够信息,太细致又会膨胀占用 context 空间。
例如,可以让 key_findings 和 decisions_made 保存带证据出处的简短结论,把原始数据另存为可回查记录。但一句话也可能遗漏限制;验收状态要由程序根据结果更新,不能只追求文字短。
常见的状态管理失误 #
1. 用日志代替状态
把所有工具调用结果堆进 context,期望模型自己从中提炼状态。这样会让当前进度依赖模型从原始记录中再次推断。应直接检查请求中是否包含正确目标和已验证状态,而不只检查日志是否完整。
2. 状态对象和 messages 双轨但不同步
维护了任务状态对象,但忘记在工具调用完成后更新它,导致状态对象和实际执行历史出现偏差。可以在工具执行后的结果处理步骤中更新状态,也就是 postprocessing 钩子:工具成功、失败或被取消后,由这段程序把实际结果写入状态对象。
3. 压缩时过度摘要
压缩工具结果时把具体数据也丢掉了,只保留了高层总结。结果模型后续需要具体数据时找不到,只好再次调用工具。保留具体数字、错误码、关键字段,丢弃的是格式化标记和冗余文字。
4. 没有“失败记忆”
任务状态里只记录成功的步骤和发现,不记录失败的尝试和原因。导致 Agent 后续会重复相同的失败路径。blockers 和失败记录和成功记录一样重要,甚至更重要。
总结 #
程序保存过资料,与模型本轮收到资料,是两件事。状态对象负责记录实际进度;上下文构建器负责选择、组装和检查输入;长期记录需要独立存储并按需取回。
本例把这一区别落实在调用路径上:每次模型调用前都计数,完整工具交换才可进入请求;当前任务不能被裁掉,必要内容装不下就返回失败。规划状态同样需要操作结果与验收,超时或错误不会因为返回了一句自然语言而变成完成。
规划与上下文模块共有 29 项本地测试,检查实际异步截止时间、失败状态、重规划与步骤界限、多工具调用整组保留、原消息不变、当前任务保护和调用前拒绝。夹具是固定回复,没有在线模型质量或 token 上限验收;字符预算也不限制外部归档大小或模型输出。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗