如果你维护过一个超过二十轮对话的 LLM 应用大概率会遇到这种场景用户在第 3 轮告诉你“数据库连接串写在环境变量里不要提交到仓库”但在第 18 轮模型却兴冲冲地建议你把数据库密码写进config.py并加入 Git。到了第 22 轮它又开始重复问你第 7 轮已经确认过的表结构。这不是模型“变笨了”也不是提示词写得不够好而是你的应用出现了上下文漂移Context Drift。围绕这个问题很多团队的第一反应是换更大窗口的模型、加 RAG、或者做历史消息摘要。但最近 Hacker News 上有一个很有意思的个人项目标题叫 Show HN: I built a spatial node canvas to fix LLM context drift它提出了一个完全不同的思路不使用更宽的上下文窗口而是把上下文从“一维的对话流”变成“二维的空间画布”让模型和用户都能明确地看到、整理和调整关键信息。这篇文章会先讲清楚上下文漂移的本质再拆解空间节点画布这类方案的核心设计然后给出一个最小可运行的数据模型和代码实现。读完你可以自己做一个小工具或者把它作为 Memory 模块接入现有的 LangChain、Dify 之类的 Agent 流程里。1. 上下文漂移是什么先分清“模型笨”和“信息管理差”很多人把多轮对话中的遗忘问题归结为“模型不行”这其实是一个误解。我们需要先区分两类问题一类是模型推理能力不足另一类是应用传给模型的信息本身不够。如果你问一个推理能力很强的大模型“11 等于几”它不会因为聊了二十轮就答错。它之所以在长对话里遗忘早期信息是因为真正进入模型注意力范围的内容是有限的而应用通常把所有历史消息按时间顺序一股脑塞给它。模型被迫在大量低价值历史文本里寻找关键事实注意力自然会被后面几轮的内容主导。我习惯把上下文漂移拆成四种形态这样比较容易定位问题漂移类型典型表现根因局部漂移模型只记得最后两轮的内容注意力被近期 token 主导早期关键信息被稀释目标漂移对话聊着聊着模型忘记了最初的诉求原始任务被大量分支话题淹没结构漂移关键事实散落在不同消息里模型无法关联信息是时间序存储的缺少主题聚合版本漂移用户中途改过需求但模型还在按旧需求执行多条消息中的修改关系没有被显式记录从 LLM 工作原理看Transformer 的注意力机制天然更关注“位置靠后”的 token这是局部漂移的底层原因。而上下文窗口虽然越来越长但把一万行日志塞进窗口并不等于模型理解了它们。真正的问题不是窗口不够大而是关键信息没有用结构化、可检索、可更新的方式组织起来。所以判断一个方案是否真的缓解了上下文漂移不能只看它把多少文本塞进上下文而要看它是否解决了上面四种漂移中的至少一种。2. 传统方案为什么治标不治本RAG、摘要、滚动窗口的局限讨论空间画布之前先看看现在主流的三种方案为什么解决不了根本问题。2.1 RAG 不是万能药RAG检索增强生成是目前最流行的上下文增强方案。它的思路是在模型回答问题前先从外部知识库检索出相关片段拼接到提示词里。听起来合理但它有一个前提——用户提问时得知道“应该检索什么”。多轮对话里的问题恰恰在于关键信息往往是用户在前面随口给出的比如“这个项目部署到 test 环境数据库密码不要写在代码里”。如果后续没有触发相关检索词这条信息永远不会被召回。你问模型“部署时要注意什么”它可能只检索到部署文档而忽略了对话初期那个关键约束。RAG 适合解决“知识库太大需要搜索”的问题不适合解决“对话里有关键信息但不知道何时该用”的问题。2.2 摘要压缩会带来二次漂移很多长对话系统会在上下文接近窗口上限时把前面的消息做一次摘要。这比直接截断好但摘要是一次有损压缩。压缩之后细节丢失而且摘要本身又会作为一个新的“较早”文本进入上下文继续被后面的消息稀释。更麻烦的是摘要的内容取决于压缩那一刻模型的注意力。如果你在第 15 轮做摘要时模型恰好忽略了第 3 轮的某个关键约束那这个约束就永久消失了。2.3 滚动窗口是最粗暴的方案滚动窗口的优点是实现简单缺点是它把上下文看作“最近 N 条消息”。一旦关键信息滚动出窗口模型就不可能再回答正确。你只能靠用户重新说过一遍来补救。这里可以用一个类比来理解传统方案像是一张不断向上滚动的纸条新内容写在纸条底部旧内容被卷走。RAG 相当于在旁边放了一个搜索引擎但你必须先知道搜什么关键词摘要相当于定期把卷走的部分用几行字总结一下但总结本身也会被卷走。空间画布的思路则完全不同它不再把上下文当作一条纸条而是一块可以摆放、移动、关联信息卡片的白板。3. 空间节点画布的核心设计节点、连线与视口这个 Show HN 项目提出的空间节点画布核心是三个概念节点Node、连线Edge和视口Viewport。节点一段独立的信息比如一个需求、一个配置项、一个表结构、一个决策记录。连线节点之间的关联关系比如“依赖”“导致”“与某节点冲突”。视口用户或模型当前关注的区域。画布上节点很多但一次只把视口附近的节点注入上下文。为什么空间语义有效因为人类处理复杂信息时空间布局本身就是一种记忆线索。你回忆一个项目的架构时脑子里很可能是“左边是网关右边是订单服务数据库在下面”而不是按时间顺序回想每一封邮件。空间画布把这种心智模型外化成工具让 LLM 也能“看到”信息的组织结构。另外空间位置还能表达优先级和关联度。你可以把最重要的节点拖到画布中心把次要节点放在边缘。模型编译上下文时离视口中心近的节点会优先进入提示词。这个机制比“给每条消息打分”更直观也更容易让用户干预。需要提醒的是不要被“空间”这个词迷惑。这里的坐标不一定是真实的欧几里得位置它可以是由语义相似度、标签层级或者用户手动拖拽决定的逻辑位置。空间画布的核心价值不是坐标而是它提供了一种显式的、可干预的、非时间序的上下文组织方式。4. 数据模型一个最小可运行的空间画布下面我们从零实现一个最小版本。这里以 Python 为例环境要求很低Python 3.10 以上即可不需要额外框架。如果你后续要接 LLM只需再安装一个 OpenAI SDK 兼容的客户端。4.1 环境准备建议创建一个独立目录和一个虚拟环境mkdir spatial-canvas-demo cd spatial-canvas-demo python -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate项目结构如下spatial-canvas-demo/ ├── canvas/ │ ├── __init__.py │ ├── model.py │ ├── canvas.py │ └── context.py ├── examples/ │ └── chat_with_canvas.py └── tests/ └── test_context_drift.py4.2 节点与画布的核心代码先定义节点模型。节点需要保存内容、位置、权重和修改时间。权重用于表示该节点在整个画布中的重要性即使离视口较远也可以兜底保留# 文件路径canvas/model.py from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional dataclass class Node: node_id: str content: str x: float 0.0 y: float 0.0 tags: list[str] field(default_factorylist) weight: float 1.0 created_at: datetime field(default_factorylambda: datetime.now(timezone.utc)) updated_at: datetime field(default_factorylambda: datetime.now(timezone.utc)) def update( self, content: Optional[str] None, x: Optional[float] None, y: Optional[float] None, weight: Optional[float] None, ) - None: if content is not None: self.content content if x is not None: self.x x if y is not None: self.y y if weight is not None: self.weight weight self.updated_at datetime.now(timezone.utc)然后是画布类。画布负责管理所有节点并提供“查找视口附近节点”的能力# 文件路径canvas/canvas.py import math from .model import Node class Canvas: def __init__(self, name: str): self.name name self.nodes: dict[str, Node] {} def add_node(self, node: Node) - None: self.nodes[node.node_id] node def move_node(self, node_id: str, x: float, y: float) - None: if node_id in self.nodes: self.nodes[node_id].update(xx, yy) def update_node_content(self, node_id: str, content: str) - None: if node_id in self.nodes: self.nodes[node_id].update(contentcontent) def remove_node(self, node_id: str) - None: self.nodes.pop(node_id, None) def find_nearby( self, center_x: float, center_y: float, radius: float 10.0, limit: int 20, ) - list[Node]: scored [] for node in self.nodes.values(): dist math.sqrt((node.x - center_x) ** 2 (node.y - center_y) ** 2) # 距离除以权重权重越高等效距离越短 effective_dist dist / max(node.weight, 0.1) scored.append((effective_dist, node)) scored.sort(keylambda item: item[0]) return [node for _, node in scored[:limit]] def total_nodes(self) - int: return len(self.nodes)这段代码里最需要注意的是find_nearby中的effective_dist。如果只按纯坐标距离排序用户手动拉远一个节点的效果会很生硬。除以权重之后高权重节点即使离视口稍远也有机会进入视野。这在后续实现“模型自主整理画布”时非常有用。5. 上下文编译把画布转成 LLM 能理解的结构化内容有了画布数据下一步是把它编译成一段适合放进 LLM 提示词的结构化文本。这里要处理两个问题一是格式要稳定二是不能把整个画布都塞进去。5.1 编译策略我推荐的做法是以视口为中心取出附近节点按有效距离排序生成一个编号列表。系统提示词里明确告诉模型“节点离视口越近越重要”。这比直接拼 JSON 更不容易被模型忽略。另外注意不要让节点内容里的 Markdown 语法污染整体格式。画布节点最好使用纯文本或简单的“编号. 内容”格式避免模型在解析时产生歧义。5.2 上下文编译器代码# 文件路径canvas/context.py from .canvas import Canvas SYSTEM_PROMPT ( 你正在一个空间节点画布上工作。\n 画布上有若干节点每个节点都包含一段与当前项目相关的关键信息。\n 节点越接近当前视口中心代表与当前任务关系越紧密。\n 请优先使用附近节点中的信息回答用户问题。\n 若节点中没有答案请明确告知不要编造。\n ) def compile_context( canvas: Canvas, center_x: float, center_y: float, radius: float 10.0, limit: int 15, ) - str: nearby canvas.find_nearby(center_x, center_y, radius, limit) lines [f[画布] {canvas.name} 当前视野内的节点] for idx, node in enumerate(nearby, 1): lines.append(f{idx}. [{node.node_id}] {node.content}) if not nearby: lines.append(当前视野内没有节点) return \n.join(lines)5.3 调用 LLM 的完整示例假设你使用的是 OpenAI 兼容的接口可以用下面的代码把画布内容注入对话# 文件路径examples/chat_with_canvas.py from openai import OpenAI from canvas.canvas import Canvas from canvas.model import Node from canvas.context import compile_context, SYSTEM_PROMPT client OpenAI( base_urlhttps://api.example.com/v1, # 换成你使用的兼容接口地址 api_keyyour-api-key, ) def ask(canvas: Canvas, center_x: float, center_y: float, user_message: str) - str: canvas_block compile_context(canvas, center_x, center_y) messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: f{canvas_block}\n\n用户问题{user_message}}, ] response client.chat.completions.create( modelyour-model-name, messagesmessages, temperature0.3, ) return response.choices[0].message.content if __name__ __main__: canvas Canvas(demo-project) canvas.add_node(Node(db_url, 数据库地址 postgres://user:passlocalhost:5432/demo, x0, y0, weight3.0)) canvas.add_node(Node(auth_rule, 登录态用 JWT过期时间 2 小时, x1, y1)) canvas.add_node(Node(dir_layout, 代码目录src/ 和 tests/ 分离, x-1, y2)) answer ask(canvas, center_x0, center_y0, user_message我登录过期时间应该设置多久) print(answer)注意示例里的数据库地址是测试环境的假地址。实际项目中画布节点可能包含敏感信息后面我会专门讲权限和脱敏问题。这段代码的关键点在于你不再把 20 轮对话历史全部交给模型而是把当前视口附近的节点内容交给模型。这样即使在长对话后期早期确认过的关键信息也能以结构化方式重新出现。6. 让 LLM 拥有编辑画布的能力工具调用与自主整理如果画布只能靠用户手动整理那它解决不了长时间运行后的维护成本问题。更实际的做法是让 LLM 具备编辑画布的能力在每一轮对话中把关键信息“写入”画布。这正是当前很多 Agent 框架在做的事情。你可以把画布操作封装成 Function Calling 工具让模型自主调用。下面是一个简化的工具定义# 文件路径examples/agent_tools.py import json TOOL_SPEC [ { type: function, function: { name: add_node, description: 在画布上新增一个知识节点用于记录用户提供的重要信息。, parameters: { type: object, properties: { node_id: {type: string, description: 节点唯一标识}, content: {type: string, description: 节点内容}, x: {type: number, description: 画布横坐标}, y: {type: number, description: 画布纵坐标}, weight: {type: number, description: 权重默认 1.0} }, required: [node_id, content], }, }, }, { type: function, function: { name: move_node, description: 移动节点到新的位置靠近视口中心表示更重要。, parameters: { type: object, properties: { node_id: {type: string}, x: {type: number}, y: {type: number}, }, required: [node_id, x, y], }, }, }, ]当模型在对话中发现某个信息非常重要时它可以调用add_node把它写入画布。下一轮对话再编译上下文时这个节点就会自动出现在视野内。这相当于给模型增加了一个“外置记忆”而不是完全依赖上下文窗口。需要提醒的是模型自主编辑画布意味着它拥有“修改状态”的能力。这在测试环境没问题但在生产环境要加审计日志和操作权限限制避免模型因为误判把关键节点删除或移动到错误位置。7. 验证方法如何判断画布确实缓解了上下文漂移很多人在做这类工具时会陷入“感觉效果变好”的主观判断。正确的做法是设计一个可量化的对照实验。一个比较简单的验证思路如下构造一个模拟长对话数据集前几轮包含几个明确的关键事实比如数据库端口、认证方案、目录规范。准备两组 AgentA 组使用普通的滚动窗口B 组使用“滚动窗口 画布注入”。在第 15 轮到第 20 轮之间提出一个只依赖早期信息的问题。统计两组回答的准确率同时记录模型是否重复提问、是否给出与关键事实矛盾的回答。你也可以用下面的伪代码思路写一个最小回归测试# 文件路径tests/test_context_drift.py from canvas.canvas import Canvas from canvas.model import Node from canvas.context import compile_context, SYSTEM_PROMPT def test_early_info_can_be_recalled_with_canvas(): canvas Canvas(demo) canvas.add_node(Node(db_url, 数据库地址 postgres://user:passlocalhost:5432/demo, x0, y0, weight3.0)) canvas.add_node(Node(auth_rule, 登录态用 JWT过期时间 2 小时, x1, y1)) # 模拟 20 轮之后的对话模型视角中心仍然保持在画布核心区域 context compile_context(canvas, center_x0, center_y0) assert localhost:5432/demo in context真实的对话测试会更复杂但核心逻辑不变验证的不是画布本身而是画布能否在高轮次、高噪声的对话中把关键信息稳定送回到模型面前。从材料看这类空间画布方案真正有效的关键在于“写回”动作。如果没有人或模型在每轮把关键信息写入画布画布就只是一个好看的空壳。所以在工程上比画布 UI 更重要的是围绕画布的写入策略、更新策略和冲突解决策略。8. 常见问题与排查思路实际使用中空间画布会遇到很多细节问题。我把高频问题整理成一张排查表问题现象可能原因排查方式解决方案画布内容过多上下文超限视口半径设置过大或 limit 值太高检查编译后的字符长度减小 radius 和 limit限制单节点内容长度模型忽略画布节点直接凭记忆回答系统提示词约束力不够尝试更换提示词表达提高节点 weight让关键节点进入列表前几位画布节点内容与对话历史矛盾用户在后续修改过需求但画布没更新检查节点 updated_at设计最新修改优先策略冲突时写新节点并标记旧节点过期节点逐渐散落到画布各处视口找不到关键信息缺少锚点节点或定期整理机制检查视口中心是否改变固定根节点/锚点节点或定期运行“复垦”逻辑模型自主移动节点导致状态混乱工具调用缺少权限约束查看操作日志限制模型只能移动 tags 包含agent-writable的节点包含敏感信息的节点被拼进上下文没有做节点分权或脱敏检查编译日志节点分级按用户权限过滤后再编译上下文格式被 Markdown 表格污染节点内容里包含复杂 Markdown 语法查看节点原始内容节点内容统一为纯文本避免表格和图片语法这里特别想强调的是“节点过期”问题。在真实项目中用户经常会修改需求。如果旧节点不标记过期画布反而会强化错误信息。一个可行的做法是每次更新需求时写一个新节点并在旧节点上追加superseded_by: 新节点ID编译上下文时过滤掉被取代的旧节点。9. 生产环境最佳实践与技术边界如果你想把空间画布从 demo 推向生产有几点建议可以参考。9.1 上下文分层管理不要把所有希望都压在画布上。建议把上下文分成三层系统层固定的系统提示词描述任务和约束。画布层当前视口相关的结构化节点。窗口层最近几轮对话的原始记录用于处理即时细节。画布负责长期记忆和关键约束窗口负责对话连贯性。这样既避免了超长上下文的高成本又不会让画布节点挤掉必要的即时信息。9.2 控制画布规模画布节点不是越多越好。节点超过一定数量后主要问题不再是空间不够而是“有效信息被噪声淹没”。建议从三个方向控制合并同类节点比如多个相关配置项合并成一个节点。设置生命周期长时间未被视口访问且权重较低的节点自动归档。关键节点锚定如项目目标、架构决策等节点不允许被常规整理逻辑移动。9.3 和 RAG 共存而非互斥空间画布不能替代检索它更适合作为工作区记忆。生产环境中比较合理的搭配是RAG 负责从静态文档库召回领域知识画布负责动态对话中的关键事实和任务目标。RAG 告诉模型“业界怎么解决”画布告诉模型“这个用户当前说过什么、目标是什么”。9.4 安全边界与审计画布节点可能保存数据库地址、密钥位置甚至业务敏感信息。上线前至少要做三件事节点分级不同用户不同可见性。脱敏处理测试环境使用占位符。审计日志记录谁在什么时候添加、移动、修改了节点。在涉及生产环境修改时任何工具调用都必须经过最小权限校验。画布作为操作接口也不例外。10. 小结上下文管理是 LLM 应用的下一个工程焦点回到这个 Show HN 项目空间节点画布不一定是最优解但它指出了一个重要的方向长对话应用不能继续用滚动日志来管理上下文。把关键信息从一维消息流中抽出来用带空间位置和关联关系的节点组织再按视口编译注入这是对抗上下文漂移的一种有效思路。如果你正在做一个多轮 Agent、代码生成助手或者复杂对话产品建议别急着上更大的上下文窗口先试试给系统增加一层“显式的工作区记忆”。哪怕只是一个很简单的 Python 画布类配上几个工具调用也能明显改善长对话中早期关键信息丢失的问题。动手做一个小版本跑通流程比等都现成框架更值得。建议先收藏本文按照第四节和第五节的代码把最小画布搭起来再逐步加入工具调用和验证流程。空间画布这个方向才刚刚开始后续值得持续关注。
