1. 为什么把“上下文”当成数据分析的核心变量
1.1 无状态分析的天花板
过去大半年我一直在折腾一个内部项目,代号叫 works_cycles,挂在 DataAI Portal 这个统一数据入口上跑。DataAI Portal 本身做的是元数据管理和自然语言查询,类似于把库表结构、业务注释、常用指标口径集中在一个门户里,再让模型基于这些信息生成 SQL。但跑着跑着我发现一个很别扭的共性:模型每回答完一个问题,就彻底“失忆”了。
用户问:“过去30天哪些设备的工作周期异常?”模型生成 SQL,查完给出清单。用户紧接着追问:“这些设备在最近一周的缺陷率走势如何?”模型此时完全不记得上一轮筛出的设备是哪些,必须让用户把设备清单再念一遍,或者把上一轮结果落成临时表再关联。这个体验,像极了刚入职的实习生——每次对话都是入职第一天,所有背景都要重新交代。
这个问题的本质,就是标题里的 Stateful Analytics 所要解决的东西:持久上下文。传统 BI 工具和 NL2SQL 系统大多是无状态请求-响应模型,查询之间互相孤立,结论无法自然衔接。对报表型需求(“本月销售额多少”)影响不大,但对探索型分析(“先定位异常,再拆原因,再验证假设”)就是灾难。
works_cycles 这个实验,就是冲着这个“失忆”问题去的。我在这台 DataAI Portal 上搭建了一套状态层,让 AI 在多次查询之间保留会话记忆、数据状态和领域知识,并在一个非常具体的业务场景——产线设备工作周期分析——上做了完整验证。这篇文章先放实验报告预览版的核心结论和实作细节,完整版还在整理。
1.2 状态化解决的真实痛点
先说明一下 works_cycles 是什么。它不是一个通用数据集,而是模拟某整车厂产线设备的工作周期数据。每条记录代表一台设备完成一个加工循环的起止时间、耗时、产出、缺陷数和状态标签。这类数据在制造业很常见,特征是:多设备、多工位、周期性、指标口径复杂。
在这种场景下做数据分析,天然就是多轮连续的。来看一个典型的分析链路:
- 找出最近30天平均循环时间超过标准值20%的设备;
- 在这些设备中,哪些集中在某个特定工位?
- 这些工位的常见故障码是什么?
- 故障码的累计出现次数,是否与班次时段相关?
第1轮筛出的设备集合,是第2、3、4轮的隐式前提。无状态模式下,每一步都要靠人工复制粘贴ID列表,或者建临时表;有状态模式下,AI 直接引用“上一轮得到的设备清单”,在上下文中用符号引用即可。
这个能力听起来简单,真正落地有一堆细节:会话记忆怎么存?引用上一轮结果时如何保证数据版本一致?上下文窗口怎么截断才不会丢关键约束?这些我会在后面的实作部分展开。先给结论:状态化分析在 works_cycles 这种多轮探索场景下,端到端准确率从 67.2% 提升到 88.6%,平均单条分析链路轮次从 4.8 降到 2.1,Token 消耗节省约 42%。
表:无状态与状态化分析的核心差异
| 维度 | 无状态分析 | 状态化分析(Stateful) |
|---|---|---|
| 多轮记忆 | 无,每轮重新解释 | 全链路保留,支持符号引用 |
| 复杂筛选复用 | 依赖临时表或人工复制 | 直接引用上轮结果集 |
| 数据口径连续性 | 易漂移,各轮可能不一致 | 显式锁版本,引用即校验 |
| Token 消耗 | 每轮重复加载元数据 | 增量加载,上下文复用 |
| 适用场景 | 单发报表查询 | 探索型、根因型、连续诊断分析 |
表格里的差异,就是我在实验里最深刻的体感:数据分析,尤其是探索型数据分析,不应该被 AI 的无状态特性切成一段段孤立的问答,上下文本身就是分析逻辑的一部分。
2. works_cycles 实验设计与 DataAI Portal 的落位
2.1 实验场景、数据准备与表结构设计
实验一开始,我面临两个选择:直接用公共数据集模拟,还是构造一套贴合业务语义的工作周期数据。最后选了后者。因为状态化分析的价值点很大程度体现在“业务规则与历史结论的复用”上,公共数据集往往缺少足够丰富的业务注释。于是搭了一套模拟产线工作周期数据,约 860 万行,涵盖 120 台设备、24 个工位、4 种班次和 87 种故障码。
核心表结构设计如下,字段命名刻意贴近生产环境习惯:
表:works_cycles 示例结构
| 字段名 | 类型 | 说明 |
|---|---|---|
| cycle_id | string | 工作周期唯一ID |
| device_id | string | 设备编号 |
| station_id | string | 工位编号 |
| shift_id | string | 班次编号(早/中/晚) |
| started_at | timestamp | 周期开始时间 |
| ended_at | timestamp | 周期结束时间 |
| runtime_seconds | int | 本周期实际耗时(秒) |
| target_seconds | int | 标准周期耗时(秒) |
| output_count | int | 本周期产出件数 |
| defect_count | int | 本周期缺陷件数 |
| fault_code | string | 本周期主要故障码(可空) |
| status | string | normal / warning / abnormal |
数据导入分三步:先建 schema,把 CSV 批量灌入;再做质量校验,重点看 started_at 和 ended_at 是否有倒挂;最后生成数据字典。DataAI Portal 在这个环节的价值是:它会自动抓取 DDL 和字段注释,生成一张数据资产卡片,后续所有模型 Prompt 里的“表结构描述”都是从这张卡片动态加载的,不用手工写死。
这一步很关键,也是后面状态化上下文的重要数据源。数据字典不是一次性写进 Prompt 就完事,而是作为“数据状态”的一部分随会话实时刷新。比如我在实验过程中修正了一个字段的业务口径(cycle_status 的 abnormal 判定阈值从 1.2 倍改成 1.35 倍),Portal 里数据字典同步更新,新会话立刻生效,旧会话则通过快照保持原口径,保证对比实验可追溯。
2.2 状态层的三种记忆
把“状态”拆开后,我最终将状态层分成了三块:会话状态、数据状态、知识状态。这个分类几乎决定了整个系统的代码组织方式。
会话状态,存的是对话过程本身:用户每一轮的自然语言提问、模型生成的 SQL、SQL 执行后返回的结果摘要。这种状态通常组织成消息列表,但存储时不能只存文本,还要为每条消息标注类型,比如 user_query、generated_sql、query_result、context_action。类型标注很重要,后面做上下文召回时,可以只召回“最近3条生成的SQL”,而不是把所有文本都塞给模型,节省大量 Token。
数据状态,存的是分析过程中派生出来的中间数据集。比如用户第一轮筛出的“异常设备清单”,这个结果本身需要有一个逻辑标识,例如 ctx_table_1。在后续轮次中,模型可以通过引用标识直接使用这份数据,不需要重新执行生成它的 SQL。在 works_cycles 实验中,我把结果集的大小限制设置为最多 5000 行,超过这个量就把完整行集替换为聚合摘要,避免上下文膨胀。
知识状态,存的是与业务语义相关的结构性规则。比如:
- 周期异常判定:runtime_seconds 大于 target_seconds 的 1.2 倍视为 warning,大于 1.5 倍视为 abnormal;
- 缺陷率计算口径:defect_count / output_count,按百分比展示,保留一位小数;
- 故障码敏感规则:涉及特定高危故障码时,查询结果必须先汇总,不得直接输出明细设备ID。
这三类状态合在一起,就是“持久上下文”的完整含义。你单纯记住对话历史,不叫真正的状态化;只有把业务规则和数据派生状态捆绑进上下文,才能支持真正连续的分析逻辑。DataAI Portal 在这里承担知识库的角色,把上述规则以可检索的文本块形式存放,查询时按语义相似度召回。
2.3 DataAI Portal 在这个实验里的角色
DataAI Portal 严格说不是模型,也不是存储引擎,它更像一个插线板:把元数据目录、数据字典、规则库、模型网关组合在同一个工作台里。works_cycles 实验的所有查询入口都走 Portal,模型调用本身则由底层的一个大模型推理服务承载。
我在 Portal 上注册了 works_cycles 数据源,生成了数据资产卡片,并配置了三条业务规则。Portal 会自动把数据字典、规则、历史查询记录整合进状态存储,随模型调用注入上下文。某种意义上,Portal 决定了“AI 能看到哪些信息”,模型本身反而退居为一个推理引擎。这与我以前直接调模型 API 做 NL2SQL 的体验差别很大:没有中间层时,每次都要手写一大段描述数据库结构的 Prompt,而且各业务线之间无法共享数据口径;有了 Portal 这一层,状态上下文变成了结构化管理,而不是纯文本拼接。
最后一个值得讲的点是版本。每个会话在 Portal 中创建时都会写入一个上下文版本号,记录三类状态各自加载的数据快照版本。这样即使后台换了大模型版本,或者调整了数据字典描述方式,也能从版本号中回溯出当时的上下文内容。对实验型项目来说,这一条几乎是“保命”的,因为 AI 系统的对比如果不锁上下文版本,实验结论基本无法复现。
3. 持久上下文的工程实作
3.1 状态组装流程实现
知道了状态是什么之后,真正困难的是:每一轮模型调用时,如何从存储的状态中组装出一份刚好够用的上下文?
我用的是一条六步链路,每一步都有明确的输入输出:
第一步,查询解析。把用户新的自然语言问题做一次轻量分类:是新的独立分析问题,还是对上一轮结果的追问?前者走冷启动,后者走状态引用。分类不靠大模型,而是用规则匹配,例如问题中出现“上述”“这些设备”“上轮结果”“该清单”等词,就判定为引用型。
第二步,语义归一化。把用户问题中的非标准化说法映射成统一的数据字典名称。比如用户说“加工时长”和“周期时间”,归一化后都指向 runtime_seconds 字段。这一步我在实验初期没做,结果模型在后续轮次反复猜字段名,导致上下文混乱。加上归一化后,SQL 生成的稳定性提升非常明显。
第三步,会话状态召回。从状态存储中取出当前线程最近的若干轮记录。数量不是固定值,而是根据上下文预算动态决定,有时候取最近3条,有时候取最近2条,但至少要保证有一条 founded SQL 和一轮 query result。
第四步,知识状态召回。根据当前问题的语义,从规则库中召回相关的业务口径。注意这里是“相关”,不是“全量”。规则库可能有几十条规则,但一次提问往往只涉及几条。做一个轻量 embedding 检索,把 Top-3 规则拼接到 Prompt。
第五步,Prompt 组装。将上面几步召回的片段,按照“指令模板 + 数据元信息 + 会话历史 SQL + 问题”的顺序拼接。
第六步,SQL 生成与状态回写。模型生成 SQL 后,先做语法校验和语义校验,通过后执行,并把执行结果与生成的 SQL 一起写回会话状态。
这条链路的伪代码大致是这个形状:
# works_cycles 状态化查询组装(简化版) def assemble_session_context(user_query, thread_id, context_budget=8192): session = state_store.get_thread(thread_id) # 1. 判断新问题是否引用历史结论 ref_type = classify_reference(user_query) # "continue" | "fresh" # 2. 归一化业务术语 -> 数据字典标准名 normalized_query = normalize_terms(user_query) # 3. 从会话状态中召回最近的SQL与结果摘要 recent_sqls = session.recent_of_type("generated_sql", limit=3) recent_results = session.recent_of_type("query_result", limit=2) # 4. 从规则库中按语义召回知识块 rules = rule_store.retrieve(normalized_query, top_k=3) # 5. 按预算调度:规则优先,历史SQL次之,结果摘要最后裁剪 prompt = build_prompt( instruction=INSTRUCTION, schema=catalog.get_table_profile("works_cycles"), rules=rules, sql_history=highlight_sql(recent_sqls), result_summary=compress_result(recent_results), question=normalized_query, budget=context_budget ) return prompt, session这个流程看起来不长,但在实验里迭代了很多轮。最初我没有数据状态这个概念,导致“异常设备清单”只能存在于结果文本中,模型要引用时就很吃力;加入 ctx_table 引用机制后,SQL 可以直接写成 FROM ctx_table_1,复杂度和准确率都大幅改善。
3.2 上下文窗口与预算策略
上下文窗口是所有 AI 系统绕不开的话题。Stateful Analytics 的核心优势是复用上下文,但如果无脑累积历史,Token 消耗会比无状态还高,而且模型会“迷路”。
我给 works_cycles 实验配置了三级上下文预算档位:
- 第一档:4K Token,用于轻量查询,只保留最新一轮 SQL 和结果摘要;
- 第二档:8K Token,标准配置,保留最近 2-3 轮完整上下文;
- 第三档:16K Token,深度分析模式,允许引用大段结果集和多个历史 SQL。
实测下来,第二档性价比最高。works_cycles 这类周期数据分析,200 条测试链路的平均上下文使用量在 7200 Token 左右,既能把连续分析链条撑起来,又不会因为过载产生幻觉。第三档只有在做根因归因分析、需要频繁对比多组设备时才会触发。
预算管理具体怎么做?我采用了一个四优先级截断策略。优先级从高到低依次是:
- 业务规则(最高,涉及口径,不能丢)
- 当前问题的原始输入
- 最近生成的 SQL 和对应结果摘要
- 更早的分析过程和中间结论(最低,可裁剪为一行总结)
按照这个优先级,当一轮组装预算超了,先砍最旧的结果摘要,再用一行字概括(“此前已分析了A、B、C三组设备”)。这样既保住了关键口径和最新逻辑,又让旧结论不会被完全遗忘。
这套策略带来的一个隐性收益是 Token 成本下降。无状态模式下,每轮都要重新加载 2400 多 Token 的完整数据字典;状态化模式下,数据字典只在会话开始时完整加载一次,后续轮次只加载增量字段描述,平均每轮节省约 44% 的 Token 开销。
3.3 状态存储、生命周期与数据版本一致性
状态存储的选型,我在实验初期试过纯内存方案,后来发现不行:服务一重启,用户的分析进度全丢,而且多人并发时状态容易串。最后改为 Redis + SQLite 的组合。
Redis 存热数据,也就是进行中的会话上下文。Key 设计为 session:{user_id}:{thread_id},Value 是一个 JSON 对象,包含上述三类状态。TTL 设置为 2 小时,超过 2 小时无人追问的会话自动过期,避免状态无限堆积。SQLite 存冷数据,每条会话结束时将完整状态归档,便于后续分析用户的分析链路、复现问题会话。
数据版本一致性是 Stateful 系统里很容易翻车的地方。举个例子:第1轮模型基于某个时间点的底表查出了设备清单,第2轮执行时底表增加了新数据,如果直接重新查询,得到的结果集可能与第1轮不一致;如果不重新查询,直接复用旧结果,又可能出现用户问题中隐含“最新数据”的矛盾。
我在实验中的处理方式是:对所有持久化状态打上数据版本号。每个会话创建时绑定当时的底表版本快照;会话存续期间查询都基于这个版本执行;如果后台数据有更新,Portal 会提示“当前会话数据版本已过期,可重新发起新会话”。对于引用型查询,优先使用会话版本的缓存结果,而不是实时扫描,因为分析连续性比数据新鲜度更重要。
生命周期这一块还有一个细节:归档会话默认保留 7 天。7 天后自动清除。清除前会把关键结论沉淀成“分析摘要模板”,用户可以将其转化为固定报表模板。这个机制目前只做了一半,完整版计划在下一阶段把摘要模板也纳入知识状态库中。
4. 实验指标、评测口径与效果对比
4.1 测试集与评价标准
评估 Stateful Analytics 不能只用单轮 SQL 准确率,因为它的核心价值在多轮连续分析。我构造了一套面向 works_cycles 的多轮测试集,共 200 条分析链路,合计约 740 轮问答,按场景分为四类:周期异常筛选、多设备对比、趋势分析、根因归结(即从异常设备追到故障码与班次关联)。
每条链路由人工预设 3 到 5 轮自然语言问题,且后一轮必须依赖前一轮的结果才能正确回答。这样设计,就是为了区分模型是“真理解上下文”,还是“靠单轮 SQL 猜出来”。
评价标准定成三层:
- SQL 正确性:生成的 SQL 语法通过,且查询结果与人工标注一致;
- 链路连续性:第 N 轮的 SQL 是否正确引用了第 N-1 轮的结果(通过符号引用或 WHERE 条件匹配来判断);
- 口径一致性:所有轮次中,同一指标的计算口径是否保持一致。
4.2 核心指标:Stateful vs Stateless
对比的基线是无状态模式,也就是说每轮都只传入固定数据字典和当前问题,不带任何历史上下文。
表:works_cycles 多轮分析主指标对比
| 指标 | Stateless | Stateful | 变化 |
|---|---|---|---|
| 端到端准确率 | 67.2% | 88.6% | +21.4pp |
| 平均链路轮次 | 4.8 | 2.1 | -56.3% |
| 平均 Token 消耗/轮 | 2850 | 1640 | -42.5% |
| 引用型 SQL 正确率 | 41.3% | 86.2% | +44.9pp |
| 口径一致性达成率 | 72.1% | 95.4% | +23.3pp |
引用型 SQL 正确率是最刺眼的。无状态模式下,模型对“这些设备”这类指代,有超过一半情况是蒙的;要么把上一轮条件重新瞎猜一遍,要么生成一个不符合语法的引用。状态化模式下,模型可以直接看到上轮生成的结果集头部摘要,并知道有一个 ctx_table_1 可用,86.2% 的引用是准确落地的。
4.3 结果解读与局限说明
这个准确率提升不是模型变聪明了,而是信息完整度变了。无状态模式要求模型通过“当前一句话”去反推隐含的全部历史条件,等于让一个人闭着眼推导中间变量;状态化模式把中间变量显式放在眼前,推理难度自然下降。
Token 的节省也同理:无状态每轮都要重新描述数据表结构和业务口径,而状态化上下文在首轮一次性加载完后,后续轮次只需增量补充。对高频探索型分析来说,省下来的 Token 是实打实的成本。
也要说清楚实验的局限。这套方案目前只适配单用户、单线索的连续分析场景,也就是“一个分析师从定位问题到拆解原因的完整链路”。对于多租户共享、跨部门复用分析结论这类场景,状态隔离和权限控制远比当前实验复杂,暂时还处于设计阶段,没有完整的实测数据支撑。所以在 3.x 章节里,我把适用边界特意框在“探索型分析链路”内,没有宣称它是数据分析的全面替代方案。
5. 实验里踩过的坑与排查心得
5.1 状态串话:多线程隔离的坑
第一次跑通状态化流程时,我去掉了多线程,单用户测试一切正常,但一旦数据门户里同时有几个人分析不同设备,就会出现“串话”:用户A在第2轮问“这些设备”,模型居然引用了用户B筛选出的设备清单。
排查下来,问题出在会话存储的 Key 设计上。最初我以为只要按 user_id 隔离就够了,但一个用户可能同时开多个分析任务。于是把 Key 改成 thread_id(线程ID),并在每次组装上下文前强制校验 thread_id 与当前查询上下文是否匹配。这个问题在无状态系统里完全不存在,但它恰恰是 Stateful 系统要付出的额外复杂度。
附加一个警告:状态层写入必须加锁。两个线程同时读写同一个会话对象,会导致上下文覆盖、丢轮次。我的做法是统一走 Redis 原子操作,不允许直接改内存对象。
5.2 截断丢约束:别把“限制条件”裁掉
8K 上下文预算在大多数情况下够用,但遇到一个特殊场景崩了。某条分析链路里,用户第1轮指定“只看 A 工位设备”,后面几轮分析中模型生成的 SQL 忽然不再带工位过滤,导致结果覆盖全部工位。
原因是第3轮时历史记录太长,截断策略把最早那条“A工位”的条件裁掉了,模型在后续引用时误认为整个分析面向全部设备。
修复方式是在截断规则里加了一条硬约束:凡是出现在历史 SQL 中、且被主查询引用的表名或过滤条件,一律不允许裁掉。如果一个 SQL 中有 where station_id = 'A01',那么这条过滤条件必须保留到该会话所有引用它的轮次结束。这个整改之后,“条件漂移”类错误下降了 70%。
5.3 禁用词与口径漂移:知识召回粒度问题
还有一类问题出在知识状态的粒度上。最初我把业务规则按“场景类型”存储,每条规则特别长,比如“周期异常判定规则”下面又包含了缺陷率、故障码、班次关联等多条细则。模型在召回时要么整条加载(Token 超预算),要么召回不到需要的子条目。
把规则拆成“单指标级别”的小块之后,情况好了很多。比如把“周期异常判定”拆成“预警阈值”“异常阈值”“枚举状态映射”三条,检索时命中更精准,上下文也更可控。
顺带提一条经验:如果当前问题与状态库中多条规则有冲突,比如用户问“最近一周的状态分布”,而规则库中既定义了“状态取值为 normal/warning/abnormal”,又定义了“warning 与 abnormal 按 runtime_seconds 判断”,模型往往会产生逻辑混乱。我的处理方式是让模型在 SQL 生成前先判断规则冲突,输出一个置信度;置信度低于阈值时,不生成 SQL,而是反问用户确认口径。这在实验里被证明比“硬猜”更好。
5.4 冷启动:新会话没有上下文,怎么办?
状态化系统的典型弱点是冷启动。第一个问题进来时,没有任何历史可以引用,效果退化成无状态模式。对特定场景而言,冷启动还不算致命,但探索型分析第一次提问往往就是复杂的,用户会直接问“找出所有连续3个周期超标的设备”,没有历史帮助时模型经常漏条件。
我尝试了一个“预热摘要”方案:在新会话建立时,Portal 自动基于当前用户的数据权限和最近高频查询标签,生成一段极简的数据轮廓摘要,比如“最近30天共有120台设备在监控范围内,异常周期占比约 6.2%”。这段摘要不包含具体设备清单,但能帮助模型建立初步的数据感知,冷启动 SQL 生成准确率提升了 9% 左右。
5.5 实验可复现性的一课
最后一个是流程问题。从实验一开始,我给每个会话都加了一个 context_version 字段,里面记录模型版本、Portal 配置版本、状态预算参数和规则库版本。没有这个字段,后续任何“这个改进好像变好了”的判断都无法构成有效证据。
在实验中期,我曾经想加大模型版本,只改了模型没有同步更新 context_version,结果那几天所有性能变化都被误判为“状态上下文逻辑带来的改进”,后来重新对齐版本号才发现趋势结论是错的。这件事之后,我把“上下文版本锁定”写成了团队实验流程里的强制项,不只是技术机制。
6. 关于下一步与个人体会
works_cycles 这个实验做完一轮后,我自己最大的体会是:Stateful Analytics 真正的工程难点不在“记住对话”,而在“把记忆组织成可引用、可校验、可裁剪的结构”。会话状态、数据状态、知识状态这三层缺一不可。只记住对话历史,你得到的是“记忆增强的聊天机器人”;把数据派生结果和业务规则也纳入状态,你才得到真正的“数据分析平台”。
后续我准备往两个方向延伸。一是把状态化上下文从单次会话扩展为跨会话的团队级知识复用,让同一个分析团队沉淀的分析结论能被后续会话直接引用;二是把数据版本校验做得更细,尝试对查询结果做敏感字段的动态脱敏,让状态化分析在更大规模的业务环境中可用。
最后再分享一个可能有点反直觉的经验:状态化系统并不适合所有分析场景。如果你只需要单发式报表查询,或者每周固定跑几张看板,无状态模式反而更简单、更稳定。Stateful Analytics 的价值在“连续追问”的探索场景里才是不可替代的。判断要不要为系统引入状态层,可以先看一个指标:用户在一个分析会话中平均追问多少轮,如果超过 3 轮,状态化大概率值得投入;如果基本都是单发提问,就别折腾了。