长任务 agent 跑到后面容易“越跑越笨”:上下文里塞满了失败的尝试、重复的网页内容和无关对话,真正重要的进度反而被淹没。CORAL 论文(arXiv:2610.40350)给了一个很朴素的思路:让 agent 自己把进度写成检查点,然后清空上下文,从干净状态继续。这个教程不训练模型,只用两个工具和一个循环,把这件事在本地复现出来。
先定清楚“检查点”里放什么
检查点不是聊天摘要,而是一份可继续执行的状态。最少包含四类字段:任务目标、已核实的事实、已完成/待完成的步骤、仍未解决的问题。事实要带来源,避免下一轮把模型自己的猜测当成证据。
checkpoint = {
"goal": "找出三家供应商中报价最低且支持 SOC2 的一家",
"facts": [
{"text": "A 报价 12 万/年,支持 SOC2", "source": "https://a.example/pricing"},
{"text": "B 报价 9 万/年,未提 SOC2", "source": "https://b.example/pricing"}
],
"done": ["收集 A/B 报价", "核对 A 的合规页"],
"todo": ["确认 B 是否有 SOC2", "获取 C 报价"],
"open": ["B 的 SOC2 状态未知"]
}把两个动作做成工具
不要指望模型“自觉”压缩上下文。给它两个明确的工具:checkpoint 写入状态,reset 清空消息但保留状态。工具描述里写死约束:reset 前必须先 checkpoint;检查点最多 2000 字;事实必须带来源。
TOOLS = [
{"type":"function","function":{
"name":"checkpoint",
"description":"保存任务进度与已核实事实。reset 前必须调用。",
"parameters":{"type":"object","properties":{
"goal":{"type":"string"},
"facts":{"type":"array","items":{"type":"object","properties":{
"text":{"type":"string"},"source":{"type":"string"}}}},
"done":{"type":"array","items":{"type":"string"}},
"todo":{"type":"array","items":{"type":"string"}},
"open":{"type":"array","items":{"type":"string"}}
},"required":["goal","facts","done","todo"]}}}},
{"type":"function","function":{
"name":"reset",
"description":"清空当前上下文,只保留最近一次 checkpoint。必须先 checkpoint。",
"parameters":{"type":"object","properties":{}}}}
]主循环:到阈值就重置
下面是一段可运行的最小实现,只用标准库。它调用任意 OpenAI 兼容接口(把 BASE_URL、MODEL、API_KEY 换成你的)。每轮把检查点作为 system 消息注入;当消息总长度超过阈值,先要求模型 checkpoint,再清空历史。
import json, os, urllib.request
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000/v1")
MODEL = os.environ.get("MODEL", "qwen3-8b")
KEY = os.environ.get("API_KEY", "none")
def chat(messages, tools=None):
body = {"model": MODEL, "messages": messages, "temperature": 0}
if tools: body["tools"] = tools
req = urllib.request.Request(
BASE_URL.rstrip("/") + "/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type":"application/json","Authorization":"Bearer "+KEY})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())["choices"][0]["message"]
def run(task, max_turns=40, char_limit=24000):
state = None
messages = [{"role":"system","content":"你是执行任务的 agent。上下文过长时先 checkpoint,再 reset。"},
{"role":"user","content":task}]
for turn in range(max_turns):
msg = chat(messages, TOOLS)
messages.append(msg)
if msg.get("tool_calls"):
for call in msg["tool_calls"]:
name = call["function"]["name"]
args = json.loads(call["function"]["arguments"] or "{}")
if name == "checkpoint":
state = args
result = "saved"
elif name == "reset":
messages = [
{"role":"system","content":"继续任务。检查点:"+json.dumps(state, ensure_ascii=False)},
{"role":"user","content":"从检查点继续,先列出下一步。"}]
result = "reset ok"
messages.append({"role":"tool","tool_call_id":call["id"],"content":result})
if sum(len(str(m.get("content") or "")) for m in messages) > char_limit:
messages.append({"role":"user","content":"上下文接近上限:先调用 checkpoint 保存进度,再调用 reset。"})
if msg.get("content") and "FINAL" in msg["content"]:
return msg["content"], state
return "max turns", state把 reset 做成硬约束
模型可能一直 checkpoint 却不肯 reset,也可能 reset 后反复重做。两个办法:一是在代码里限制“连续两次 reset 之间必须完成至少一个 todo”;二是 reset 后只给检查点,不给旧消息,避免它从垃圾上下文里恢复旧错误。
def can_reset(state, last_state):
if not state: return False
if last_state is None: return True
return len(state.get("done", [])) > len(last_state.get("done", []))怎么验证它真的有用
别只看一两个 demo。准备 20 个多步任务(检索、对比、填表各几条),同一模型跑两组:A 组普通 ReAct,B 组加 checkpoint/reset。记录四个数:任务成功率、输入 token、总耗时、重置后关键事实丢失率。CORAL 论文在 GAIA 上报告 8B 模型平均 42.8%、输入 token 比 ReAct 少 6 到 7 倍,但你的任务分布不同,必须自己测。
- 成功率:按“最终答案是否满足验收条件”判,不看措辞。
- 输入 token:统计所有请求的 prompt tokens,重置本身也有成本。
- 事实保留:在检查点里埋 5 个关键事实,重置后逐条问,看是否还在。
- 错误复现:同一错误在重置后是否再次出现,判断检查点有没有把错误固化成“事实”。
三个常见坑
第一,检查点漂移:模型会把猜测写进 facts。写入前要求每条事实必须有 source,没有来源的放到 open。第二,检查点膨胀:不限制长度,几轮后又变成另一个长上下文;按字数截断,并优先保留未完成事项。第三,工具循环:模型反复 checkpoint 不推进;给每轮设“必须产生一个新事实或完成一个 todo”的检查,否则强制结束。
什么时候不该用
任务很短、或者强依赖逐字上下文(合同审阅、代码 diff 讨论)时,重置会丢细节,不如直接加大窗口。另一个前提是任务可被写成结构化状态;如果进度本身难以言说,先做人工拆解,再谈自动检查点。
这套方法的价值不在“省 token”这一个指标,而在把长任务的失败变得可诊断:是事实没记住、是下一步丢了,还是重置后走错方向。先把检查点做小、做准,再考虑论文里的强化学习训练。