Agent开发 4.3 - 上下文与状态实战落地:共享黑板模型与增量状态合并

2026-10-26 | 81分钟 | yrobot | Agent开发, 共享黑板, 增量状态合并, 上下文工程, Reducer模式, 状态管理 | 系列:Agent开发

在多轮对话或复杂长程任务中,用户的需求与客观事实往往是分批、碎片化且动态变化的。例如在留学规划、代码重构或客服咨询等业务中,用户在第一轮可能交代了专业背景和成绩,第三轮补充了预算限制,第五轮又对最初的目的地意向进行了修改。

如果系统仅靠将全量对话历史无脑塞进上下文窗口,不仅会面临 Token 线性膨胀与注意力稀释问题,更会导致前后自相矛盾的信息在上下文中同时存在,使大模型无所适从。

工业级 Agent 系统的标准解法是引入共享黑板模型(Shared Blackboard),将分散在各轮次对话中的事实提炼为一份强类型的结构化状态(Profile / State)。但随之而来的核心工程难题是:如何确保每轮提取出的状态变更,能够安全、精准地合并进现有状态中?

本文将从 3 个核心设计维度剖析状态维护方案,拆解直觉方案在生产中引发的数据丢失灾难,并深度横向对比业界两大成熟方案——“字段级最终态替换”与“操作原语 Reducer 归约”的机制、适用场景与工程取舍。


状态维护与合并的 3 个核心设计维度

在设计 Agent 的状态管理方案时,工程师通常需要在以下 3 个核心维度上做选型与权衡:

flowchart TD
    subgraph D1["维度一:状态载体形态 (State Representation)"]
        D1_1["原始对话历史追加 (Append-only History)\n全量上下文堆叠,依赖模型临时推断"]
        D1_2["自然语言摘要 (Text Summary)\n定期压缩为文本段落,丢失强类型约束"]
        D1_3["强类型结构化黑板 (Typed Blackboard / Profile)\n明确的 TypeScript Schema,可做业务校验与驱动"]
    end

    subgraph D2["维度二:状态更新执行主体 (Update Executor)"]
        D2_1["大模型每轮全量重写 (Full LLM Rewrite)\n要求大模型输出全量状态 JSON,极易静默抹除字段"]
        D2_2["代码浅合并 (Object.assign / Spread)\n浅层覆盖,集合被无脑覆盖,null 冲掉有效旧值"]
        D2_3["模型语义裁决 + 字段最终态替换 (Field-level Resolver)\nLLM 结合已有状态直接推导变动字段最终值,代码按 Key 覆盖"]
        D2_4["模型提取操作补丁 + 代码纯函数归约 (Operational Delta + Reducer)\nLLM 只提炼操作意图 (add/remove),确定性代码执行规则合并"]
    end

    subgraph D3["维度三:冲突仲裁策略 (Conflict Arbitration)"]
        D3_1["最新输入无条件覆盖 (Last-Write-Wins)\n后来的输入直接冲掉旧事实,无法防范模糊闲聊"]
        D3_2["字段级操作原语 (Operational Patch)\n显式区分 add、remove、reset,彻底消除集合增删二义性"]
        D3_3["置信度分层硬门禁 (Confidence Level Guard)\n权威录入数据锁定,低置信度推测不可覆盖高置信度事实"]
    end

1. 维度一:状态载体形态 —— 事实数据以什么格式保存?

  • 原始对话历史追加:系统不单独维护状态,直接把历轮用户对话和工具返回一直往后追加。这种做法导致上下文长度与调用成本呈线性膨胀,且容易在长文本中引发注意力分散。
  • 自然语言摘要:定期让大模型把历史对话压缩成一两段总结性文本。虽然降低了 Token 消耗,但自然语言无法直接对接到下游的程序逻辑(如前端表单组件、数据库筛选器、计算引擎等),丢失了强类型约束。
  • 强类型结构化黑板:定义清晰的接口契约(如 TypeScript Interface),将事实抽取为标量字段、集合与键值对象。状态具有确定性,下游业务系统与规则检查器可随时消费。

2. 维度二:状态更新执行主体 —— 谁来负责将新信息写入状态?

  • 大模型每轮全量重写:每一轮交互都把旧状态和新一轮输入喂给大模型,让大模型直接重新输出一份包含全部字段的完整 JSON。
  • 代码直接浅合并:大模型提取增量后,在代码中直接使用 { ...current, ...delta } 进行浅拷贝赋值。
  • 模型语义裁决 + 字段最终态替换:大模型同时读取已有状态与最新对话,仅对发生变动的字段直接输出最终计算结果(例如直接输出计算好的最新数组),代码层做安全字段级覆盖。
  • 模型提取操作补丁 + 代码纯函数归约:大模型仅输出操作意图(如添加某项、删除某项),由无副作用的代码纯函数(Reducer)执行数学级差并集运算。

3. 维度三:冲突仲裁策略 —— 新旧信息冲突时以谁为准?

  • 最新输入无条件覆盖:只要有新值就直接覆盖旧值。这种朴素策略在遇到集合修改或模糊闲聊时,会产生严重的副作用。
  • 字段级操作原语:对集合类型提供明确的操作动词(add、remove、reset),不再把数组当作单一值粗暴覆盖。
  • 置信度分层硬门禁:为状态字段附加置信度元数据,区分“用户明确权威录入”与“模型模糊闲聊推测”,在代码层拦截低置信度数据对高置信度事实的冲刷。

直觉方案的两大工程灾难

很多开发者在刚接触 Agent 状态管理时,往往倾向于选择最省事的方案:要么每轮让大模型重写全量 JSON,要么用 JavaScript 对象的展开运算符合并。在实际工程中,这两种方式都会引发严重的数据损坏。

1. 灾难一:大模型每轮全量重写引发的“静默字段丢失”

让大模型每轮生成全量状态 JSON,表面上看把所有逻辑交给了模型,实现非常简单,但在真实对话中存在三大致命缺陷:

  1. 注意力转移导致的静默抹除:随着会话轮次增加,当用户在第 5 轮全神贯注地与 Agent 讨论“学费和预算上限”时,大模型的注意力高度聚焦在资金计算上。此时要求它把完整的 20 个用户字段一次性吐出,它极易把前几轮提到的 GPA、托福成绩或研究意向漏掉,或者直接赋值为 null。程序不会抛错,但用户的核心数据已被永久抹除。
  2. 缺乏因果审计能力(Zero Auditability):当发现数据库中的某个字段变动时,开发者无法排查究竟是用户明确要求修改,还是大模型发生了幻觉。
  3. Token 与网络时延的无意义放大:哪怕用户只说了一句“是的,我确认”,大模型也必须将几百个 Token 的完整 JSON 重新生成一遍,造成计算资源浪费。

2. 灾难二:JavaScript 浅合并引发的“数组覆盖”与“空值冲刷”

为了规避全量重写的开销,另一种直觉做法是让大模型只提取变动字段 delta,然后在代码中浅合并:

// 错误示范:看似简洁的浅合并
const nextProfile = { ...currentProfile, ...deltaProfile };

这种做法在处理真实业务状态时存在两个确定性的 Bug:

  • 集合字段被直接整盘替换:如果第一轮用户说“想去美国和新加坡”,旧状态中 targetCountries 为 ["美国", "新加坡"];第二轮用户说“英国也考虑一下”,大模型提取出的 Delta 为 { targetCountries: ["英国"] }。执行浅合并后,之前的美国和新加坡瞬间被覆盖,最终只剩下 ["英国"],完全丢失了历史意向。
  • 空值冲刷(Anti-null 缺失):大模型在返回结构化增量时,经常会将“本轮未提及的字段”显式序列化为 null 或 ""。执行展开运算后,原本已经确立的有效值直接被 null 覆盖冲刷。

增量状态合并的两大主流工业级方案

为了既避免全量重写的性能与丢失陷阱,又解决简单浅合并的数据冲刷问题,工业界演化出了两套各具特色的成熟方案。

flowchart TD
    subgraph SolutionA["方案 A:模型语义裁决 + 字段级最终态替换 (Field-level Resolver)"]
        direction TB
        A_Input["输入:当前已有状态 + 本轮最新对话"] --> A_LLM["LLM 语义推算变动字段的最终值\n(输出 Partial<State>)"]
        A_LLM --> A_Code["代码层字段级安全替换\n(非空防 Null,直接写入字段)"]
    end

    subgraph SolutionB["方案 B:操作原语补丁 + 代码纯函数归约 (Operational Delta + Reducer)"]
        direction TB
        B_Input["输入:仅本轮最新对话"] --> B_LLM["LLM 提取原子操作意图\n(输出 SetPatch { add, remove } / 点路径)"]
        B_LLM --> B_Code["代码纯函数执行数学级归约 (Reducer)\n(并集、差集、置信度门禁拦截)"]
    end

方案 A:模型语义裁决 + 字段级最终态替换(Field-level Resolver)

很多工程师会提出一个非常符合直觉的设想:只要我们把黑板数据设计得足够扁平,为什么不把“当前已有状态”和“最新一轮对话”一起喂给大模型,让大模型只提取发生变动的字段,并直接输出该字段合并后的最终值呢?

1. 运行机理

在此方案中,大模型扮演的是“字段级最终态求解器”:

  • 用户第一轮选了 ["美国", "新加坡"],已存入状态;
  • 第二轮用户说:“除了美国,把英国也加进来,另外新加坡我不去了”;
  • 大模型同时看着旧状态和新输入,凭借强大的语义理解直接计算出受影响字段的最终值:{ targetCountries: ["美国", "英国"] };
  • 代码层只需要对这些显式变动的 key 进行安全覆盖。
// 方案 A 代码实现:字段级安全替换(具备防 null 与空串校验)
export function applyFieldLevelDelta<T extends Record<string, any>>(
  current: T,
  delta: Partial<T>
): T {
  const updated = { ...current };

  for (const [key, value] of Object.entries(delta)) {
    // 严格防 Null / Undefined 冲刷
    if (value === undefined || value === null) continue;
    if (typeof value === "string" && value.trim() === "") continue;

    // 直接以模型推导出的最终值覆盖对应字段
    updated[key as keyof T] = value;
  }

  return updated;
}

2. 方案 A 的核心优势

  • 对复杂自然语言集合运算的“降维打击”:面对高度复杂的排除与筛选语义(如“除了常春藤名校,其他所有美国学校都剔除,但追加帝国理工和南洋理工”),纯代码很难写出通用的集合计算规则;而大模型借助语义理解,能够极其自然地直接输出最终数组。
  • 数据结构与代码极简:无需定义额外的操作补丁协议,大模型输出的类型就是 Partial<Profile>,无认知负担。

3. 方案 A 的局限与工程代价

  • 并发多 Agent 写入时的“更新丢失”(Lost Update):在多智能体系统中,若选校 Agent 和预算 Agent 基于同一份旧状态并行运行,选校 Agent 算出的最终国家是 ["美国", "英国"],预算 Agent 算出的最终国家是 ["美国", "日本"]。后写入的 Agent 会将先写入者的结果整盘覆盖,造成严重的竞态数据丢失。
  • 大状态黑板下的 Token 与时延成本:每轮提取增量都必须附带当前旧状态。当黑板上挂载了长文摘录、多份文档或几十个实体时,Prompt 会迅速膨胀,成本与延迟显著增加。
  • 模型权力过大导致的权威数据误篡改:由于大模型对字段具有整盘重写的权限,当遇到模糊闲聊输入时,极易因过度泛化而误将已有权威事实冲刷掉。

方案 B:操作原语补丁 + 代码纯函数归约(Operational Delta + Reducer)

为了解决多智能体并发竞态、大状态 Token 消耗以及因果审计问题,分布式系统与复杂 Agent 系统普遍采用 操作原语补丁 + Reducer 归约模式。

1. 运行机理

在此方案中,大模型不承担计算最终值的职责,仅充当“操作意图提取器”:

  • 大模型无需读取完整的全局已有状态,仅分析本轮用户输入,输出结构化的操作动词;
  • 由代码层确定性的纯函数(Reducer)负责执行数学级的差集、并集运算与权限仲裁。
// 集合字段的操作契约
export interface SetPatch<T> {
  add?: T[];      // 本轮需要追加的元素
  remove?: T[];   // 本轮明确要求剔除的元素
  reset?: T[];    // 本轮要求全量替换的列表
}

export function mergeSet<T>(current: T[] = [], patch?: SetPatch<T>): T[] {
  if (!patch) return current;
  if (patch.reset) return Array.from(new Set(patch.reset));

  // 先剔除明确要求删除的项,再追加新增项
  const filtered = current.filter(item => !(patch.remove || []).includes(item));
  const additions = patch.add || [];
  return Array.from(new Set([...filtered, ...additions]));
}

2. 深层嵌套:状态扁平化优先与点路径增量(Dot-notation Path Patch)

在设计黑板数据结构时,必须遵循一条核心原则:黑板状态尽量保持扁平化(Normalization)。

正如基准教程《Hello-Agents》与主流实践所揭示的,超过 3 层嵌套的深度 JSON 会显著降低大模型的字段定位准确度,极易引发“修改子字段却漏掉同级兄弟字段”的失控。

如果业务上必须保存深层属性,补丁包也不应从根节点开始包含整棵大树,而应使用点路径(Dot Path)表达变动:

// 点路径补丁只包含发生变动的叶子节点
const delta = {
  "education.undergraduate.gpa": 3.8,
  "budget.annualMaxRmb": 400000
};

// 合并器顺着路径精确修改对应叶子,绝不触碰兄弟属性
function setByPath(target: Record<string, any>, path: string, value: any): void {
  const segments = path.split(".");
  let current = target;
  for (let i = 0; i < segments.length - 1; i++) {
    const key = segments[i];
    if (!(key in current) || typeof current[key] !== "object") {
      current[key] = {};
    }
    current = current[key];
  }
  current[segments[segments.length - 1]] = value;
}

3. 冲突仲裁:置信度硬门禁防御模糊闲聊

真实业务中最隐蔽的故障,是“用户随口的一句闲聊冲刷掉了此前严格录入的权威数据”。例如用户此前已上传了托福 105 分的权威事实,随后闲聊说了一句“语言这块其实随便看看吧”,大模型如果把“随便看看”作为新值写入语言成绩,就会破坏已有事实。

针对此类场景,需要构建三重防线:

  1. 版本历史(Revision Log):关键字段记录修改历史,保证即使被覆盖也能回溯历史版本。
  2. LLM 意图决策提取:在提取增量时,将已确认的核心事实作为只读上下文输入给 LLM,要求 LLM 判断该变动应执行 OVERWRITE、APPEND 还是 IGNORE。
  3. 代码层置信度硬门禁:在数据结构中标记置信度级别(如 verified 权威录入 vs inferred 闲聊推测)。合并器遵循铁律:低置信度的增量绝对无权直接覆盖高置信度的现有数据,只能作为临时备注或记录审计日志。

方案 A 与方案 B 的架构选型全景权衡

在真实工程落地中,这两套方案并非非此即彼,而是针对不同系统规模与并发度的客观映射:

选型考量维度 方案 A:字段级最终态替换 (Resolver) 方案 B:操作原语补丁 + Reducer 归约
最终值裁决方 大模型直接推导最终值,代码仅做覆盖 代码纯函数执行数学规则与门禁运算
复杂自然语言集合运算 极强(擅长理解“除某某之外全删”等复杂语义) 依赖模型显式拆解为原子 add/remove 项
系统与协议复杂度 极低(直接基于 Partial<State> 操作) 中等(需维护 SetPatch 或点路径补丁协议)
多 Agent 并发写入 存在严重的更新丢失竞态(Lost Update) 天然支持并发安全原子合并
Token 与时延开销 随状态体积线性增加(需携带当前黑板快照) 极低(Prompt 仅包含本轮对话几句话)
审计与事件溯源 仅记录前后快照,难以还原微观原子动作 天然支持 Event Sourcing 事件溯源与重放
最佳适用场景 单 Agent、扁平轻量画像、复杂自然语言集合筛选 多 Agent 共享黑板、超大状态黑板、高安全审计系统

最小可用 ProfileMerger 纯函数实战

结合方案 B 的确定性优势与工程防守规则,我们可以用一个高信噪比、无副作用的 TypeScript 纯函数,实现具备防空值覆盖、集合操作与完备度评分的状态合并器:

export interface ApplicantProfile {
  targetMajor?: string;
  gpa?: number;
  maxBudget?: number;
  targetCountries: string[];
}

export interface ProfileDelta {
  targetMajor?: string | null;
  gpa?: number | null;
  maxBudget?: number | null;
  targetCountriesPatch?: SetPatch<string>;
}

export interface MergeResult {
  updatedProfile: ApplicantProfile;
  completenessScore: number;
  missingFields: string[];
}

export function mergeProfile(current: ApplicantProfile, delta: ProfileDelta): MergeResult {
  const updated: ApplicantProfile = {
    ...current,
    targetCountries: mergeSet(current.targetCountries, delta.targetCountriesPatch),
  };

  // 标量防 Null 覆盖
  if (delta.targetMajor !== undefined && delta.targetMajor !== null && delta.targetMajor.trim() !== "") {
    updated.targetMajor = delta.targetMajor.trim();
  }
  if (delta.gpa !== undefined && delta.gpa !== null) {
    updated.gpa = delta.gpa;
  }
  if (delta.maxBudget !== undefined && delta.maxBudget !== null) {
    updated.maxBudget = delta.maxBudget;
  }

  // 计算完备度与缺失核心字段
  const missing: string[] = [];
  let score = 0;
  
  if (updated.targetMajor) score += 0.3; else missing.push("目标专业");
  if (updated.targetCountries.length > 0) score += 0.3; else missing.push("意向国家");
  if (updated.gpa !== undefined) score += 0.2; else missing.push("GPA 绩点");
  if (updated.maxBudget !== undefined) score += 0.2; else missing.push("预算范围");

  return {
    updatedProfile: updated,
    completenessScore: Math.round(score * 100) / 100,
    missingFields: missing,
  };
}

结语

在长程 Agent 架构中,上下文工程绝不仅限于“如何压缩 Token 窗口”,更核心的是如何维护一份精准自洽的全局状态(SSOT,Single Source of Truth)。

  1. 破除单一极端假设:面对状态增量更新,既不能退化为高风险的“全量重写”或“简陋浅合并”,也不必盲目在所有场景强推重型协议。
  2. 轻量单 Agent 场景善用语义裁决(方案 A):如果状态规模可控、主要是单 Agent 与用户自然语言交互,让大模型带上下文直接推导变动字段的最终值,是开发效率最高、自然语言理解最流畅的实用选择。
  3. 并发与复杂系统坚守 Reducer 模式(方案 B):一旦涉及多 Agent 协同写黑板、超大状态载荷或严苛的安全防冲刷要求,必须严格实施操作原语补丁与代码纯函数归约,用确定性代码筑牢系统的一致性底座。