最近在梳理 Agent 工程的落地细节时,我越来越强烈地感觉到一个矛盾:模型能力在快速进步,但很多复杂任务最后不是“模型不够聪明”而崩掉的,而是“上下文管不住”而崩掉的。比如一个智能客服 Agent,用户前面聊了订单信息,中间问售后流程,最后又绕回订单状态。如果你把全部对话原文都塞进模型上下文,很快会遇到几个现实问题:token 上限触顶、关键信息被淹没、单次请求成本直线上升。手工让模型总结?摘要又经常丢细节,订单号错一位、售后状态记反,处理起来非常头疼。
最近看到阿里开源的 Scroll 项目,核心思路让我觉得值得认真聊一聊:与其让模型把上下文“背在脑子里”,不如让模型自己写代码来管理上下文。换句话说,Scroll 不是给模型换一个更大的窗口,而是给模型配一个“记事本管理员”,让记忆从模型的隐性能力变成显性的、可编程的工程能力。这篇文章会围绕这个思路展开,先讲清楚它到底解决了什么问题,再拆解它的核心原理,然后给出一个概念级 Demo 和工程落地建议,最后聊一聊哪些场景适合它、哪些场景不建议盲目上。
1. 为什么上下文会成为 Agent 的真正瓶颈
很多开发者第一次接触 Agent 时,会觉得“上下文”就是 prompt 里那几段文字,不够就继续拼。但实际跑一个稍复杂的任务就会发现,上下文管理是整个系统最容易出问题的地方,而且出问题的方式非常隐蔽。
第一个问题是物理上限。每个模型的上下文窗口都有明确长度,多轮对话、工具调用结果、中间脚本输出、参考文件内容都会挤占空间。一个看起来不太复杂的跨天任务,可能在十几个来回之后就无路可退,只能截断历史。而截断是粗暴的,它不会替你分辨哪些信息重要,哪些不重要。
第二个问题是信息衰减。即使上下文长度没有超限,模型也不一定真的“看”了所有内容。研究表明,模型对长文本中部的信息往往敏感度更低,这可能和注意力分布有关。放到 Agent 场景里,如果订单号出现在第十二轮、售后记录出现在第二十轮,到第三十轮时模型大概率会凭“印象”编一个答案,而不是回头去翻准确信息。
第三个问题是成本。大模型调用费用和 token 数量直接挂钩,而且大多数模型的 attention 计算会随序列长度上升。每轮对话都重复处理全部历史,意味着同一个事实会被反复计费、反复计算。上下文越长,单次请求的耗时和费用都同步上涨,这在生产环境里是非常现实的约束。
第四个问题是状态一致性。一个复杂任务通常会被拆成多个子步骤,步骤之间的中间状态靠什么传递?如果只靠对话历史,每一步都可能成为污染源。前一步的误解读、截断、或模型随机生成的一句话,都可能带偏后面的所有决策。这本质上已经不是模型智力问题,而是系统设计问题。
所以我的判断是:上下文不应该只被当成 prompt 拼料,它更接近一个外部系统的状态。我们要解决的是状态如何存储、读取、更新、压缩和审计。Scroll 的核心,就是把这一层用代码显式表达出来。
2. Scroll 的思路:从“硬记”到“编码”
在 Scroll 之前,开发者处理长上下文主要有四种方案,每一种都有明显缺陷。
方案一是窗口截断。超过窗口就删最早的内容,实现最简单,代价是信息丢失不可控。方案二是模型摘要。把历史对话交给模型压成一段话,再继续跑。摘要适合“大概意思保留”的场景,但缺少精确性,而且每次摘要都是不可逆操作,一旦摘要错了,原始信息已经回不来。方案三是 RAG 检索,把文档切块向量化,按相似度找回。RAG 在开放域知识问答里很有用,但在强状态、强顺序、强关系的任务里表现不稳定,比如订单状态流转、多轮表单填写,用户问“刚才那个订单怎么样了”,语义相似度并不足以精确找回“刚才”对应的实体。方案四是外部状态。人手工设计 JSON 或数据库结构,Agent 读写这些结构。这个方案更接近工程化,但状态结构是预设的,一旦任务超出预设,Agent 就不知道该怎么更新状态了。
Scroll 的思路更进了一步:状态结构不靠人预先写死,而是由模型在运行时生成代码来定义和维护。模型既是任务执行者,也是状态管理器的编写者。它可以根据当前任务需要,生成一段更新状态文件或数据库的代码,代码执行后,外部状态就变成了一个真实存在的、可查询、可回滚、可审计的工程产物。
这个思路把上下文管理从“模型的隐性行为”变成了“显式代码”。原来的问题是模型面对一串越来越长的历史,它的注意力是概率性的,可能漏看,可能记错。新方案是:历史的关键信息被结构化保存到外部,模型每一轮只需要基于一小段“种子上下文”做决策,需要细节时可以通过代码去查,就像人做项目时不用把整个对话背下来,而是随时翻看自己的笔记和表格。这个转变非常关键,它意味着上下文长度不再是任务复杂度的直接函数。
我们用一个生活中的类比帮助理解。会议长达两小时,如果要求你全部记住所有数字、日期和结论,你一定崩溃。但如果你安排一个助理在旁边做结构化笔记,会议结束时拿着笔记做总结,事情就变得可控。Scroll 所做的是让模型自己扮演那个助理,并且用代码把“做笔记”这件事做得可执行、可追溯,而不是靠模型临场发挥。
3. 核心原理:模型、代码执行器与外部状态的三层协作
从架构角度看,Scroll 的设计可以抽象成三个组件:模型、代码执行器、外部状态存储。三者的关系不是串行调用,而是一个迭代循环。
先说外部状态存储。它可以是本地文件、JSON、SQLite、数据库或对象存储,关键特征是状态独立于模型上下文存在。模型不在了、会话重启了、换一个模型厂商了,状态还在。这个特性对于生产 Agent 尤其重要,因为会话可能跨小时甚至跨天,模型实例可能发生切换。
再看代码执行器。它负责运行模型生成的代码,并限制代码的权限。模型生成的代码不是直接执行在宿主机上的,而是放进一个受控环境,比如沙箱容器或子进程。代码能访问什么路径、能调用哪些 API、执行时长上限是多少,都由执行器的策略控制。这个设计是工程安全的关键,因为模型生成代码本身是一件动态的事情,不能完全信任它的每一次输出。
最后是模型。它在这个循环里承担两个职责:第一,基于当前任务目标和种子上下文,决定下一步需要什么信息;第二,生成一段代码,把需要更新的信息写进外部状态。模型不再是所有信息的载体,它只需要在每一个决策点读取一小段关键状态,其他信息都放在外部。
整个迭代循环大致是:
第一步,观察。模型拿到任务目标、会话阶段、上一步输出后的状态摘要。第二步,规划。模型判断当前这一步需要哪些新信息,有没有需要保留到后续步骤的关键数据。第三步,生成代码。模型生成一段代码,用来更新外部状态,比如新增一条记录、修改订单字段、追加文件索引。第四步,执行。代码执行器在沙箱中运行这段代码,并返回执行结果。第五步,提取种子。执行完成之后,系统把外部状态压缩成一个短小的“种子上下文”,比如最新状态快照加上未完成任务清单。第六步,推理。模型基于种子上下文继续回答或调用工具,进入下一轮循环。
这个循环有一个很明显的优势:模型上下文里需要携带的内容不再是全部历史,而是“决策所需的最小集”。它让上下文长度趋于稳定,而不是随着对话轮次线性膨胀。同时,因为每一步状态变更都落在了外部存储里,任何一步出错都能回溯,理论上甚至可以回滚到上一个版本。这是传统“把所有东西塞进窗口”的模式完全做不到的。
4. 为什么“写代码”比“写摘要”更适合复杂任务
有人会问:模型直接写一段摘要不是更省事吗?为什么非要生成代码去管理状态?这里面的差别,恰好是 Scroll 这类方案真正的价值所在。
先看摘要的本质。摘要是一个高度压缩过程,它把一组事实映射成一段自然语言,而这个映射是有损的。当摘要只有两三句话时,它可能保留“订单已退款”这个结论,但丢掉了“退款金额 88.5 元、原支付渠道 13 号账单、用户要求开具电子发票”这些次级但重要的细节。更麻烦的是,摘要是一次性生成过程,无法在事后局部修正。想让摘要多保留一个字段,只能重新生成,而重新生成可能改变其他内容。
代码的本质则完全不同。代码不再是一次性压缩,而是一种可重复执行的状态转换。模型生成的结构化操作可以只更新一个字段,其他字段原样保留。例如:
# 生成一段更新订单状态的代码 def update_order(orders, order_id, new_status, note=""): for order in orders: if order["order_id"] == order_id: order["status"] = new_status order["updated_at"] = "2025-01-20 14:30:00" if note: order["notes"] = note return order return None这段代码执行后,只有指定订单的位置被修改,其他所有数据都不受影响。这是摘要类方案做不到的精细度。
再看可测试性。代码可以被静态检查、单元测试、在测试环境跑一遍,确认没有破坏数据结构后才进入生产。摘要无法做形式化验证,只能靠人肉眼判断。对于金融、客服、数据分析这类对准确度要求高的场景,代码的验证能力是很大的优势。
还有可审计性。代码有明确的执行记录,谁在什么时间修改了哪个状态字段,都能通过日志还原。摘要则是黑盒,你只有最终一段文字,无法知道它基于哪些原始信息得出的结论。
从工程角度看,摘要适合“传递语义”的场景,代码适合“维护状态”的场景。Scroll 把状态维护这份工作完全代码化,等于让 Agent 拥有一个可以随时增删改查的“结构化记忆库”。这才是它区别于传统提示词工程的核心。传统提示词工程是让人去适配模型的输入输出,Scroll 是让模型去创建和维护外部系统的状态,两个方向完全不同。
5. 概念级实现:搭建一个由模型管理上下文的 Agent 骨架
下面我会给出一个概念级 Demo,用来演示“模型生成代码管理上下文”这个核心循环。需要先说明:以下代码不是某个具体 SDK 的官方调用方式,也不是 Scroll 的真实 API,只是为了讲清楚设计模式而写的示意实现。真实项目中请以对应官方仓库和文档为准。
5.1 环境准备
本文示例使用 Python 3.10 及以上版本,不需要安装第三方框架。我们会用标准库完成文件读写和子进程执行。所有演示均在本地完成,不含任何线上接口调用。
建议先创建一个实验目录:
mkdir scroll-demo && cd scroll-demo目录结构如下:
scroll-demo/ ├── main.py ├── context/ │ └── state.json └── sandbox_runner.py5.2 状态文件:context/state.json
状态文件是 Agent 的外部记忆仓库。初始状态下,它只包含一个空的任务记录列表:
{ "current_task": "处理用户订单查询与售后引导", "customer": { "user_id": "u_1024", "name": "李明", "recent_orders": [] }, "history_log": [], "open_questions": [] }这个文件相当于 Agent 的“笔记本”。每一轮之后,代码更新这个文件,下一轮模型基于更新后的文件做决策,而不是重新阅读整段对话。
5.3 沙箱执行器:sandbox_runner.py
模型生成的代码不应该直接运行在宿主环境里。为了演示,我们用一个子进程来执行模型生成的脚本,并设置超时时间。这里的核心思路是:代码最多运行 10 秒,超出即终止,避免模型生成死循环。
# 文件路径:scroll-demo/sandbox_runner.py import subprocess import sys import tempfile import os TIMEOUT_SECONDS = 10 def run_generated_code(code: str, state_file: str) -> str: """在受限子进程中执行模型生成的代码,并传入状态文件路径。""" # 把模型生成的代码包装成一个可脚本化的临时文件 wrapper = f""" import json import os state_file = {state_file!r} def load_state(): with open(state_file, 'r', encoding='utf-8') as f: return json.load(f) def save_state(state): with open(state_file, 'w', encoding='utf-8') as f: json.dump(state, f, ensure_ascii=False, indent=2) {code} """ with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as f: f.write(wrapper) tmp_path = f.name try: result = subprocess.run( [sys.executable, tmp_path], capture_output=True, text=True, timeout=TIMEOUT_SECONDS, cwd=os.path.dirname(os.path.abspath(state_file)), ) if result.returncode != 0: return f"执行失败: {result.stderr}" return result.stdout except subprocess.TimeoutExpired: return f"执行超时(>{TIMEOUT_SECONDS}秒)" finally: os.unlink(tmp_path)这个执行器做的事情很简单:把模型生成的代码片段包装成一个临时 Python 脚本,传入 state_file 路径,然后在一个新子进程中运行。子进程不继承当前进程的全局变量,天然形成了一层基础隔离。
5.4 主循环:main.py
主循环模拟的是“模型生成代码 → 执行器运行 → 状态更新 → 提取种子上下文”这个过程。为了让示例不依赖外部 API,我们用一个模拟函数代替模型推理。你可以在真实环境里把 mock_model_generate_code 替换成任意模型的接口调用。
# 文件路径:scroll-demo/main.py import json from sandbox_runner import run_generated_code STATE_FILE = "context/state.json" def load_state(): with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) def save_state(state): with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def mock_model_generate_code(seed_context: str, state: dict) -> str: """模拟模型生成的状态管理代码。真实环境请替换为 LLM 接口调用。""" # 这里返回一段写死的代码,用于演示状态更新流程。 return ''' order_data = { "order_id": "A10086", "product": "智能门锁", "price": 899.0, "status": "已付款", "logistics": "待发货" } state = load_state() state["customer"]["recent_orders"].append(order_data) state["history_log"].append({ "step": "用户输入订单号A10086,系统查询到订单信息", "context_summary": "订单A10086已付款,等待发货" }) state["open_questions"] = ["用户之后可能追问发货时间"] save_state(state) print("状态已更新:新增订单A10086") ''' def extract_seed_context(state: dict) -> str: """把外部状态压缩成下一轮模型的种子上下文。""" customer = state.get("customer", {}) recent_orders = customer.get("recent_orders", []) order_lines = [ f"订单{o['order_id']}: {o['product']},状态{o['status']}" for o in recent_orders ] return ( f"当前任务:{state.get('current_task', '')}\\n" f"用户:{customer.get('name', '')}\\n" f"近期订单:\\n" + "\\n".join(order_lines) + "\\n" f"待跟进问题:{state.get('open_questions', [])}" ) def agent_loop(round_count=1): state = load_state() for _ in range(round_count): seed_context = extract_seed_context(state) print("=== 本轮种子上下文 ===") print(seed_context) print() code = mock_model_generate_code(seed_context, state) print("=== 模型生成的代码 ===") print(code) print() result = run_generated_code(code, STATE_FILE) print("=== 执行结果 ===") print(result) print() state = load_state() if __name__ == "__main__": agent_loop()这段代码的关键设计有两点。第一,模型和状态文件之间通过代码解耦,代码执行成功后状态文件才发生变化。第二,每一轮结束后的 seed_context 是压缩的,它只包含当前任务、用户信息和近期订单摘要,不包含完整对话历史。
运行方式:
python main.py预期输出会依次显示种子上下文、模型生成的代码、执行结果。程序结束后,state.json 里会多出一条订单记录,history_log 里会增加一行操作日志。
这个示例虽然简单,但已经体现了整个思路的基本形状。你可以把它理解成一个最小可运行的 Scroll 模式骨架。继续扩展时,最值得替换的部分就是 mock_model_generate_code,把它变成真实模型调用,并让模型根据当前任务动态生成更新代码。
6. 运行结果与效果验证
示例跑通后,验证点主要有三个:状态文件是否按预期更新、种子上下文是否保持精简、历史日志是否可追溯。
运行前先确认 state.json 中 recent_orders 是空数组。运行python main.py后,再打开 state.json,能看到类似下面的内容:
{ "current_task": "处理用户订单查询与售后引导", "customer": { "user_id": "u_1024", "name": "李明", "recent_orders": [ { "order_id": "A10086", "product": "智能门锁", "price": 899.0, "status": "已付款", "logistics": "待发货" } ] }, "history_log": [ { "step": "用户输入订单号A10086,系统查询到订单信息", "context_summary": "订单A10086已付款,等待发货" } ], "open_questions": [ "用户之后可能追问发货时间" ] }如果每一步都成功,你会看到三个明确信号。
第一个信号是状态文件发生了预期变更。新增订单信息出现在 recent_orders 中,说明模型生成的代码被正确执行,并且状态是可持久化的。第二个信号是种子上下文输出非常短。回到终端可以看到打印的 seed_context 只有几行,但 state.json 里存了完整订单对象。这说明模型只需要依赖一个紧凑摘要,就能继续推进任务,而不必把所有原始对话重新读一遍。第三个信号是 history_log 的存在。无论后续出了什么问题,我们都能通过这个日志了解“这个状态是哪个步骤写入的”,这一点在传统对话系统中很难实现。
如果运行失败,优先检查三个位置。第一,看终端输出的错误信息。如果提示ModuleNotFoundError: sandbox_runner,说明 main.py 和 sandbox_runner.py 不在同一目录,或者你从其他目录运行了命令。第二,如果提示 JSON 解析错误,说明 state.json 的格式被破坏,请检查文件是否被其他程序改写,并确认逗号、引号是否完整。第三,如果执行超时,说明模型生成的代码可能存在死循环,需要在沙箱执行器中进一步收紧超时时间,并限制循环次数。
7. 真实场景下的使用边界与风险
概念 Demo 跑通之后,更重要的是一盆冷水:这个方案并不是“银弹”,在生产环境里它有几个非常现实的风险点。
第一个风险是代码安全性。模型生成的代码天然不可完全信任。它可能因为出错而删除文件,也可能因为被注入恶意指令而执行危险操作。所以在真实项目里,代码执行器必须做多层防护:使用独立容器或虚拟机运行、以最小权限账号执行、禁止网络访问、限制文件系统可写范围、严格控制依赖库。不要在图省事的情况下直接把模型生成的代码exec在当前进程里,这等于把 Agent 的完整权限交给一个概率模型。
第二个风险是状态一致性。多轮任务中,状态文件可能被多个环节并发读写。如果没有锁或版本号机制,两个步骤同时写同一个 JSON 文件,后写入方可能覆盖前写入方的数据。生产环境建议使用带版本号的存储,或者直接使用数据库表记录状态变更,每次更新都是插入新版本,而不是原地覆盖。
第三个风险是审计困难。虽然代码执行日志比摘要好追踪,但状态文件本身仍然可能被直接刷写。如果状态文件可以被人工编辑,而你没有办法区分这次修改是模型生成代码造成的,还是人工调试造成的,审计链路就断了。建议给状态变更记录加上批次号,每次执行模型生成的代码时,把代码内容本身也存到审计表里。
第四个风险是场景错配。如果任务只是简单的单轮问答,比如“帮我写一封邮件”,引入“模型写代码管理上下文”反而增加了复杂度,响应的首字延迟也会变高。这个方案适合的是复杂多步、长会话、需要精确记忆的任务,不适合高频低延迟的轻量场景。
安全方面也要特别提醒:在涉及用户数据、订单信息、支付记录等敏感数据时,必须在合规前提下进行数据脱敏和权限控制,不能让 Agent 的状态文件变成敏感信息的裸奔仓库。所有状态读写都应当经过授权检查,关键操作要有审计记录,并且生产环境必须遵守最小权限原则。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 模型生成的代码频繁语法错误 | 模型缺少状态文件结构的足够描述 | 查看错误日志中 Python traceback | 在 prompt 中附上 state.json 的 schema 示例,并要求先输出 JSON 校验结果 |
| 代码执行超时 | 模型生成死循环或执行了资源消耗过高的操作 | 查看沙箱超时日志和资源占用 | 缩短超时时间,限制循环次数,复杂数据处理交给独立任务队列 |
| 状态文件被清空或字段丢失 | 模型代码里出现全量覆盖赋值 | 对比最近一次审计日志中的代码内容 | 沙箱中禁止直接写整个 state.json,只能通过白名单函数更新 |
| 种子上下文仍然太长 | 状态对象嵌套过深、冗余字段太多 | 检查 extract_seed_context 输出 | 对状态做精简,只保留当前步骤必需字段 |
| 多轮后出现重复写入 | 状态查询未做幂等控制 | 查看 history_log 中同订单是否出现多次 | 在状态更新代码中加入订单号去重逻辑 |
| 跨会话恢复失败 | 会话 ID 没有绑定到状态文件 | 检查状态文件命名和每次请求参数 | 为每个会话维护独立状态目录,并增加会话 ID 校验 |
这些问题的共性在于:一旦你接受“模型用代码维护上下文”这个前提,所有传统分布式系统的基础问题都会找上门来。所以它不是一个可以偷懒的技术方案,它只是把问题从模型层转移到了工程层,而工程层的问题是你可以用成熟手段解决的。
9. 最佳实践与工程建议
结合上面的思路,这里整理几条实践建议,适合从 Demo 走向生产的开发者参考。
第一,给 Agent 设计一个“迷你文件系统”。不要把所有状态塞进一个巨型 JSON。更好的做法是分目录管理,比如context/memory/{会话ID}/下面放订单、对话摘要、工具调用记录、用户偏好等独立文件。模型生成代码时,也更容易定位到目标文件,而不是频繁加载全量状态。
第二,为状态更新定义白名单接口。不要让模型直接写原生文件操作代码,而是给它一组预设函数,比如add_order(order_data)、update_status(order_id, new_status)、append_log(entry)。模型只需要做“填空式”调用,大大降低生成代码出错概率。这也是很多实际项目比完全自由生成更稳妥的原因。
第三,每一轮变更都写审计日志。审计日志不只是记录“改了什么”,还要记录“为什么改”。做法是让模型在生成代码前,先输出一句意图描述,代码执行后系统把这句意图和实际代码一起存下来。这样后续复盘时,你能理解每一步的动机。
第四,状态需要定期压缩。虽然外部状态不会占模型窗口,但文件本身会越来越大。可以设置一个压缩策略:当状态文件中某类历史记录超过 N 条时,把超过部分归档到冷存储,只保留最近 N 条在当前状态文件里。这样可以避免每次加载文件、解析文件的开销越来越大。
第五,给代码执行器加“人在回路”的开关。对于关键状态变更,比如退款、删除用户数据、修改金额,不要直接执行模型代码。让模型先输出一个变更计划,人工确认后,再执行。这种开关可以做成默认关闭、按需开启,但对高风险操作建议强制开启。
第六,前后端都要考虑回滚。状态文件建议使用版本化命名,比如state_0001.json、state_0002.json,每次更新都生成新版本。出问题时可以快速回退到上一个版本,而不需要从日志里手工重建状态。这个成本很低,收益却很直接。
第七,不要忽略向模型传输的 schema 信息。你可以在 prompt 中带一段精简的字段说明,让模型知道哪些字段必须保留哪些字段可以丢弃。这个看似细节,实际上可以显著减少模型生成错误代码的概率。
10. 总结与后续学习方向
Scroll 这类方案带来的最大启发,不是某个参数或某个 API,而是思路上的转变:把模型上下文从“一次性提示文本”变成“外部系统状态”,让模型用代码来维护记忆。它让上下文管理变得可扩展、可追踪、可回滚,也把 Agent 的可靠性问题从概率层拉回到工程层。
接下来你可以做的第一件事,是把上面的 Demo 跑通,再看清楚种子上下文、状态文件、代码执行器这三个角色之间的关系。然后试着把 mock_model_generate_code 换成真实模型调用,给它一个具体任务,观察模型会不会按预期生成状态更新代码,在哪些地方会出错。这一步跑完,再考虑是否引入沙箱容器、数据库存储、审计服务。
如果做的是复杂 Agent、长时间运行的任务型系统,或者客服、数据分析、自动化运营这类强状态场景,这个思路非常值得深入实验。如果项目只是一个简单问答工具,可以暂时不用引入这套复杂度。判断标准很简单:你的任务是不是依赖跨多轮的精确记忆?如果是,那 Scroll 这条路就值得继续走。建议先把状态文件和审计日志放进本地 Git 仓库,每跑一轮都看一眼 diff,你会非常直观地看到“上下文”到底是怎么被模型管理起来的。