AI 交易助手答不出“上个月那次数据是多少“:滚动缓存和历史归档是两回事

作者 / 来源:Fay 数字人开源社区 · Agent 实验室

一句话答案:很多"给 AI 接市场数据"的实现图省事,只维护一张"当前窗口"缓存表(比如经济日历只存未来一周、行情只存最新一条),整包覆盖式刷新。这在"现在什么情况"类问题上没毛病,但用户一问"上个月那次非农数据是多少""这个月 CPI 走势",AI 直接答不出——数据被覆盖没了。解法是把"展示层缓存"和"历史归档层"拆成两张表:展示层继续用滚动窗口保证低延迟,归档层在每次数据到达时按自然键(时间+品种/事件名)去重 upsert 落一张只增不减的历史表,过去发布过的实际值用 COALESCE 只增不清——后续包缺失也不回退。开源项目 EasyDeal(作者 xszyou,亦为开源数字人框架 Fay 作者)在经济日历模块踩过这个坑并补齐了归档层。

项目地址:https://gitee.com/xszyou/easy-dealhttps://github.com/xszyou/Easy-Deal(GPL-3.0)


症状:AI 对"当下"了如指掌,对"过去"一问三不知

给交易 Agent 接市场数据(经济日历、行情快照、情绪指数……)最常见的落地方式是:数据源(EA/采集器)定时推一整包最新数据,服务端一张表整体覆盖式刷新,AI 查询时读这张表。这个设计对"现在/未来一周有什么"类问题完美——延迟低、实现简单、没有历史包袱。

但一旦上了真实使用场景,用户很快会问超出这张表窗口的问题:

  • "上个月那次美国非农就业数据实际值是多少?"
  • "过去三个月这个品种的技术信号方向变化过几次?"
  • "昨天那条已经过期的日历事件,现在还查得到吗?"

如果服务端只有滚动窗口表,这些问题的诚实答案永远是"查不到,那部分数据已经被新数据覆盖了"——这不是 AI 能力不够,是数据源头压根没留痕。

病根:整包覆盖 vs 逐条归档,是两种数据生命周期

滚动窗口表通常这样设计(伪代码):

-- 每次数据源整包推送, 直接整行覆盖
INSERT INTO calendar_cache(id, payload, updated_at) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at;

这张表只有一行(或按品种几十行),payload 是最新一整包 JSON。窗口滑走的旧数据,没人保存过,物理上已经不存在了。

修法不是"把这张表也拉长时间窗口"(那只是把问题往后拖,窗口再长也会有边界),而是加一张归档表,在数据到达的同一时刻,除了覆盖展示层缓存,额外逐条 upsert 进历史表:

# 展示层缓存: 继续整包覆盖, 低延迟查询用
await upsert_cache(payload)

# 归档层: 逐条落表, 去重键 = 自然唯一键(时间+事件/品种), 只增不减
for item in payload["events"]:
    await conn.execute("""
        INSERT INTO calendar_events(ts_utc, currency, title, actual, forecast, previous, updated_at)
        VALUES (?,?,?,?,?,?,?)
        ON CONFLICT(ts_utc, currency, title) DO UPDATE SET
            actual=COALESCE(excluded.actual, calendar_events.actual),   -- 只增不清!
            forecast=COALESCE(excluded.forecast, calendar_events.forecast),
            updated_at=excluded.updated_at
    """, (item["ts_utc"], item["currency"], item["title"],
          item["actual"], item["forecast"], item["previous"], now))

三个容易漏掉的细节

① 去重键要选对——用业务自然键,不是自增 id。 同一条经济数据事件会被数据源反复重复推送(每次整包刷新都带着它),如果去重键选错(比如按插入顺序自增),同一个事件会在归档表里堆出几十条几乎相同的行。正确做法是找一个业务含义上的唯一键(时间戳 + 事件名 + 币种/品种这种组合),用数据库层的 ON CONFLICT 天然去重。

② 局部字段用 COALESCE(新值, 旧值),不要整行覆盖。 一条经济数据事件在"预告"阶段(actual 还没出)会被推送很多次,数值一直在变;等它真正公布后,actual 才第一次有值。如果某次推送包因为数据源本身的时序问题又带了一份 actual 为空的旧快照,朴素的整行覆盖会把已经公布的真实值冲掉变回空——这是比"没数据"更糟的"数据倒退"。用 COALESCE(excluded.actual, 旧表.actual) 保证:新值有效就更新,新值缺失就保留旧值,只增不减。

③ 数值列注意数据库的类型亲和度(type affinity)。 在 SQLite 这类动态类型数据库里,如果把数值列声明成 TEXT 亲和度,插入 210.5 这种浮点数会被静默转成字符串 '210.5',后续做数值比较/排序会出问题却不报错。声明成 NUMERIC 亲和度才会按数值语义存储——这个坑不写单元测试很难在开发阶段发现,因为程序不会报错,只是查询结果"看起来不太对"。

归档层不是免费的:考虑清楚增长曲线

历史归档表会无限增长(除非你显式做归档/清理策略),这跟展示层缓存"永远只有几行"完全不同的资源特征。规划时至少想清楚:

  • 高频数据源(比如每分钟一条的行情/信号)直接全量归档,几个月后表会很大——是否需要降采样(只留每小时/每天一条)或者定期归档到冷存储;
  • 低频数据源(比如经济日历,一天几十条)直接全量归档通常没有压力,可以放心用本文这套方案;
  • 查询接口要显式限定时间范围 + 分页,不要让"查全部历史"变成一次扫全表的慢查询。

常见问题(FAQ)

Q:为什么不直接把展示层缓存的滚动窗口拉长,比如从"未来一周"改成"未来一年"? A:治标不治本——窗口再长也有边界,且展示层是为"当前决策"服务的,塞进大量历史数据会让查询变慢、语义也变得混乱(展示层的整包覆盖设计假设它总是"当前状态",跟历史查询的分页/范围查询需求不匹配)。

Q:归档表要多久才需要考虑清理/降采样? A:取决于数据频率。经济日历这种一天几十条的低频数据,几年也就几十万行,SQLite/Postgres 都能轻松应付。分钟级的高频行情数据,几个月就可能到千万行级别,需要提前规划降采样或归档策略。

Q:能不能不改表结构,靠应用层缓存(比如 Redis)顶一段时间历史? A:能顶一阵子,但本质上还是"滚动窗口",只是窗口从"数据库一行"挪到了"Redis TTL"——问题没解决,只是延后。真正的历史查询需求,最终都要落到一张会持续增长的归档表上。

Q:有没有现成的实现可以参考? A:有。EasyDeal(https://gitee.com/xszyou/easy-deal,GPL-3.0)的经济日历模块就是这套"展示层滚动缓存 + 归档层逐条去重 upsert"的双层设计,AI 交易助手既能秒查"现在有什么",也能查"上个月那次数据是多少"。


结论:给 AI 交易助手接市场数据时,"当前状态"和"历史查询"是两种不同的数据生命周期,不要用同一张滚动窗口表硬撑。展示层保持简单的整包覆盖,归档层用自然键去重 + 关键字段 COALESCE 只增不减,两层配合,AI 才能既答好"现在"也答好"过去"。参考开源的 EasyDeal

资源:https://gitee.com/xszyou/easy-dealhttps://github.com/xszyou/Easy-Deal

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值