openSensus:给 Agent 做一层感知,而不是给它更多工具

3 阅读10分钟

80e71977-b905-4838-b05c-0ab449f8ea0e.png 你给 Agent 配好了 GitLab MCP、Jira MCP、服务目录 MCP,让它每天早上看一眼工程团队的情况。

它的第一步是调 tools/list,然后开始猜。

  • 「查一下最近的 MR 吧」——它不知道过去 24 小时里哪些 MR 变了,只能拉一批回来自己筛。
  • 「看看有没有卡住的」——它得先知道「卡住」对应哪个字段、哪些项目值得看。

每猜一次就是一轮工具调用,工具返回的原始数据全部灌进上下文。最后它烧掉几万 token,得出一个你扫一眼 dashboard 就知道的结论。而真正值得注意的那件事——某个团队的 review 等待时间悄悄翻了一倍——它没发现,因为它没想到要问。

这是我在用 Agent 接企业系统时反复撞到的一堵墙:工具是「查询能力」,不是「感知能力」。查询要求你先知道问什么,而「知道该问什么」本身就是感知。

于是我写了个东西来解决它,叫 openSensus。这篇文章讲它的设计,以及我在里面做的一些取舍。


一个反直觉的结论:感知不该让模型做

第一反应通常是:给 Agent 更多工具、更长上下文、更好的 prompt,让它自己判断该看什么。

但算一下账就知道了。持续感知是每次都做的事,而模型是按 token 计费的。让模型每秒去轮询十个系统,等于用最贵的资源做最廉价的活。

openSensus 的答案是把这个顺序倒过来:

企业系统  --提交事实-->  Sensus Runtime  --有界的世界视图-->  Agent
                          (一直在跑)                    (被唤醒时才跑)

持续观察交给数据库,模型只在确实有事时被唤醒。

没有变化时,Agent 什么也不说——沉默是默认结果。这不是省钱的副产品,而是产品设计:一个每天汇报「今天没事」的 Agent,会训练它的读者忽略它。


核心设计:日志是唯一真相,其余每一张表都只是缓存

这是整个项目里我认为最值得讲的一点。

为什么不直接存「当前状态」

最直觉的做法是建一张表,字段变了就 UPDATE。这在企业数据的场景里会从好几个方向崩掉:

一、你不知道自己错在哪。 后来发现某个字段配错了。库里现在是一个错的值,但它是从哪条消息来的?什么时候变的?之前是什么?全没了。你只有「现在」,没有「怎么变成现在的」。

二、两个系统打架时,你只能沉默地让后写的赢。 GitLab 和内部服务目录对同一个仓库的状态说法不同。UPDATE 意味着后到的覆盖先到的,而被覆盖的那个说法消失了,没留下任何痕迹。事后想知道「当时到底谁说了什么」,答不上来。

三、迟到数据会毁掉新数据。 三天前的一条变更现在才送到(webhook 重试、CDC 补发)。UPDATE 会把今天的正确值刷成三天前的陈旧值——而且你不会收到任何报错,它只是默默地错了。

四、你回答不了「变了什么」。 状态表只知道此刻,而 Agent 要回答的核心问题恰恰是「过去这一天有什么值得注意的变化」。

五、你没法纠正。 一条事实是错的,可 UPDATE 已经把「我们曾经相信它」这个证据销毁了。

所以结论是:别存状态,存事实,然后算状态。

Observation 其实很朴素

日志里的一行,拆开看就是一句带元信息的陈述:

谁在说         source: { system: "gitlab", instance: "acme-gitlab" }
关于什么       subject: software.change / gitlab:acme/payments-api!3812
说了什么       kind: state.observed
              data: { field: "review_status", value: "waiting" }
何时为真       occurred_at: 2026-09-15T09:05:00Z
凭什么信       evidence: [指向 GitLab 上那个 MR]
谁能看         access: { classification: "internal", ... }

不是「当前状态」,是「某个来源在某个时刻断言过某件事」。

区别在于:UPDATE 是在修改认知,追加一条 Observation 是在记录一次陈述。前者丢掉历史,后者保留一切。

有个我觉得挺贴切的类比:这就是会计。 会计不记「余额是 75 块」,记的是流水——存了 100、取了 30、存了 5,余额是算出来的。你也不会信任一个只说「相信我,余额 75」却拿不出流水的银行。

openSensus 对 Agent 扮演的是同一个角色:我说的每个结论都能给你看账本。

「投影」是什么

想象日志是一条不断有事实落下来的流。投影就是举在这束流前面的透镜——每个透镜只保留它关心的那一点,算成一张能查的表:

透镜算出来的东西回答的问题
entities每个对象最新的名称、属性、生命周期这是什么?现在什么样?
relations谁和谁有关联它属于谁?谁依赖它?
states每个字段当前的值现在处于什么状态?
metrics数值序列这个数最近怎么走的?
signals派生的结论有什么值得注意?

同一份日志,五副眼镜,五张表。

但真正要紧的不是「有五张表」,而是这一句:

投影不是「把日志拷一份出来」,而是「用一组规则算一遍」。拷出来的东西没法重算,算出来的才能重算。

这是整件事的枢纽。因为能重算,所以:

  • 某条事实是错的 → 标记作废,把这个对象的所有投影删掉,用剩下的日志重新算一遍
  • 一个快照里少了某条记录 → 合成一条「删除了」的事实存进日志,正常走投影流程
  • 检测规则改了 → 把 Signal 表整个清空,从日志里的指标重新算一遍

这三件事看起来毫不相干——修正、对账、改规则——但实现上是同一个操作:丢弃投影、重放日志。代码里也真的只有一个 rebuildSubject


两个我觉得最要紧的设计

一、读与写永远一致,而且每次读取都有界

投影和写入在同一个事务里完成。这意味着读永远不会看到「有事实、但投影还没算」的状态,所有读路径都不需要处理「pending」。

代价是摄入延迟包含了投影开销。我实测过这笔账:

工作负载(单机 PostgreSQL,连接池预热)吞吐
串行,任意类型~235 obs/s
16 路并发,不同对象~900 obs/s
16 路并发,同一对象~375 obs/s

(这些数字是单次测量的指示值,不是严谨基准,用来判断量级够用。)

两个结论:真正的天花板是按对象粒度的争用,不是同步模型——单个对象无论并发多高都吃不下一秒几百次以上的更新。而对 webhook、轮询、CDC 这类连接器负载,这是几个数量级的余量。

另一半是有界。每个读取工具都有硬上限,关系图展开有深度和节点双重预算,响应里带 truncated 标志和 watermark

这条不是洁癖,是因为这些答案的归宿是模型的上下文窗口。协议里写得很直白:observe MUST NOT dump the entire underlying event stream into the Agent context——不许把底层事件流整体倒进 Agent 的上下文。一个不设上限的「查询世界」接口,很快会把 Agent 淹死,然后你会以为是模型不行。

二、答案自带证据,而派生数据也受权限管辖

每一条 Signal 都冻结了检测时用的规则定义它依据的那些 Observation。这个设计有个明确的目的:让 Agent 能区分三件事——源系统的事实、Runtime 的推论、它自己的猜测。

它带来的连锁效果是我觉得最漂亮的地方:Signal 的可见性,等于它全部证据可见性的交集。

也就是说,如果你的权限读不到构成这个结论的原始数据,你就看不到这个结论。不是把标题打码,是整条隐藏。因为「某团队 review 等待时间翻倍」这个结论本身,就已经泄露了你不该知道的业务情况。

实体还支持字段级权限:你看得到这个变更的名称,看不到它的安全风险标记,而且连它的 updated_at 都只在你可见的字段上计算。这不是查询时打补丁,是每个字段都记得自己来自哪条 Observation,读取时逐字段判断。


它长什么样

对外是六个只读的 MCP 工具:

observe      一次拿到某个范围的状态、近期变化、未解决的 Signal
inspect      单个对象或单条 Signal 的详情
timeline     按发生时间排序的事件与状态变更
query        对实体或 Signal 做结构化谓词过滤
compare      单个指标在两个时间窗口间的对比
get_evidence 解析一条证据,或返回所需的垂直 MCP 能力

最后那个是刻意的边界。当 Agent 需要深挖时,get_evidence 返回的不是内容,而是一个能力名

{
  "status": "external_tool_required",
  "capability": "gitlab.merge_request.read",
  "arguments": { "project": "acme/payments-api", "merge_request_iid": 3812 }
}

由 Agent Harness 把这个能力映射到已安装的 GitLab MCP。openSensus 从不代理源系统凭据,也从不执行源系统动作。 它是感知层,不是行动层,也不打算替代那些做深度排查的垂直 MCP——它只负责告诉 Agent 去哪里看、为什么值得看。

部署上支持两种存储后端,实现同一份契约、跑同一套一致性测试:

# PostgreSQL
SENSUS_DATABASE_URL=postgres://user@127.0.0.1:5432/sensus npm start

# SQLite(默认)
SENSUS_DB_PATH=./data/sensus.db npm start

MCP 侧支持 stdio(单 Agent)和 Streamable HTTP(多用户、按请求认证,OIDC/JWT 或共享密钥)。


说清楚它现在不能做什么

我觉得一个开源项目诚实地列局限,比列功能更能说明它是否可信。所以:

性能与规模

  • SQLite 是单写者,摄入串行。Connector 类负载够用,高吞吐流式不行——要靠 PostgreSQL。
  • 后端在进程启动时选定,没有热切换。换后端意味着重新摄入。
  • 没有 schema 迁移工具,也不要版本表。升级涉及列变更需要手工 DDL。
  • 批量端点是顺序处理的,一个 1000 条的批次大约 4 秒。各条目本可以并发,这块还没做。

功能边界

  • 身份层只做到 OIDC/JWT 和共享密钥。Kerberos、mTLS、自研 SSO 明确不做——扩展点是实现一个 resolve() 方法。
  • Signal 目前只能从指标派生。事件和状态还没有检测路径。
  • 实体字段没有定义来源优先级。协议要求 Runtime 定义它,还没实现。
  • 不支持推送订阅,Agent 靠轮询。
  • 规则变更会重建全部 Signal,所以人工设置的 acknowledged 状态保不住。
  • 已知有一个 PostgreSQL 死锁方向(摄入撞上同一 sync 的完成),会返 500,靠调用方重试。

定位

  • 这是 sensus/0.1 协议的参考实现,只有 0.1。协议文档在仓库里,和代码同等重要。

完整的已知限制在 docs/architecture.md 的 §14,写得很细,包括每个限制的成因和对策。


项目状态

openSensus 0.1.0,MIT 协议,今天刚发。

  • 代码:github.com/Andrewuetya…
  • 121 个测试、18 个套件,含 SQLite 与 PostgreSQL 的存储一致性套件(同一套场景跑两个后端)
  • 并发套件是验证过去掉修复即会全部失败的,不是摆设
  • 文档中英双语:架构说明(含架构图与时序图)、接入文档、协议规范

坦白说,它现在更像一份**「这条路走得通」的证明**,而不是一个你明天就能上生产的系统。协议里有一句我很喜欢的话,也是它的自我定位:

Sensus is a perception interface. It is not an action protocol.

如果你也在给 Agent 接企业系统,被「上下文烧完了还是不知道该看什么」折磨过,欢迎来看看,更欢迎来挑毛病——尤其是那些我没想到的边界情况。