最近被 Dots 类产品刷屏的时候,我第一反应不是“又要多一个效率神器了”,而是“这一套交互逻辑终于有人做出来了”。一个个信息节点像零件一样散落在画布上,AI 在里面分类、连接、生成行动项,整个思考过程不再躲在对话气泡背后,而是变成看得见、可拖拽、可修改的结构。可惜这类产品目前是闭源的,数据落在别人平台,玩法也由厂商定义。作为一个习惯把 AI 能力捏在自己手里的开发者,我更在意的是:能不能用现成的开源框架,快速搭出一个类似的工作台。于是我把目光放到了 CopilotKit 上,折腾了大概两个星期,做了一套可复用的方案,给它取名叫 OpenDots。
这篇文章不打算吹嘘“开源秒杀闭源”,而是纯粹从实际搭建过程出发,讲清楚 OpenDots 的架构选型、核心流程实现、几个容易踩坑的细节,以及到底什么人适合自建、什么人还是乖乖买现成的。如果你正准备在公司内部做一个 AI 工作流工具,或者想复刻 Dots 的交互形态,这篇内容应该能帮你省掉不少试错时间。
1. Dots 这类产品解决的真正问题,以及开源替代的底气在哪里
1.1 先聊清楚“Dots”究竟在优化什么
很多人第一次看到 Dots 类产品,注意力都会被画布上那些圆点吸引,觉得这只是交互上的花活。但真正用过之后会发现,它的核心价值不是好看,而是把 AI 的“思考过程”从黑箱里捞了出来。
传统 AI 对话里,你丢一段 prompt 进去,模型吐一段回答出来,中间发生了什么你是不知道的。上下文越长,模型越容易把关键信息淹没,而你除了把对话往上翻,没有任何办法干预它的内部组织方式。Dots 的思路不同:用户输入的不是一整段长文本,而是一个一个的信息点;模型的输出也不再是长篇大论,而是结构化的节点,有分类、有状态、有互相之间的连线。你随时可以拖走一个节点、改掉它的类型、切断它和别的节点的连接。
这种设计解决了三个非常实际的问题:
- 碎片化信息的沉淀。开会记录、临时想法、客户反馈、代码片段,都可以丢进去成为独立的点,不用先整理成一篇通顺的笔记。
- 上下文可控。你不需要把整段历史反复喂给模型,只需要把相关的节点集合交给它,它就能基于这个确定范围工作。
- 结果可复核。模型为什么把 A 点和 B 点关联起来,这个判断依据是可见的,不是一句“我觉得它们有关联”就糊弄过去。
闭源产品把这些体验做得越顺滑,我反而越担心一件事:数据进去之后到底被谁用了?哪天厂商调整定价、修改功能边界,自己手上的工作流是不是跟着被动?所以我说,这类产品的形态天然适合开源替代——因为它的核心资产是结构化的数据、可控的上下文、明确的节点操作,这三件事完全可以自建。
1.2 为什么“开源自建”不是反向找麻烦
有朋友会问:直接买一个现成工具不香吗?个人记办事清单的话,确实没必要折腾,订阅一个月也没多少钱。但一旦场景换成公司内部流程、私有数据、审计需求,闭源就变成硬伤。
举个例子。某团队想用 Dots 类产品来梳理售后工单,把客户反馈自动归类到产品缺陷库。如果数据放在第三方平台上,光是一个数据安全评估就过不了;就算过了,后续想把这个工作流嵌入到自己系统里,还得看对方给不给 API 权限。这种情况下,自建反而是一条更省心的路,因为你不用在“现有产品能力”和“业务实际需求”之间反复妥协。
OpenDots 的思路也不是让你从零写一套大模型平台,而是把 CopilotKit 这种现成的开源 Copilot 框架作为基座,把“点阵工作流”抽象成一套可复制的模板。框架负责消息流、状态同步、模型接入这些脏活累活,你只需要专注在节点画布和业务逻辑上。
1.3 CopilotKit 够不够硬,一句话说明白
说 CopilotKit 能撑起 OpenDots,原因很简单:它把做一个 AI 应用最麻烦的几个环节都提前解决了。前端有 React Hooks 和现成组件,后端有 CopilotRuntime 处理流式响应和模型适配,Agent 编排也有现成的方案。关键能力可以列一下:
- 支持流式对话输出,前端不需要自己拼 SSE 协议。
- 支持工具调用机制,模型可以操作你的业务数据。
- 支持 Agent 状态管理,多次对话之间可以共享数据结构。
- 支持上下文注入,你可以把节点内容作为可读信息交给模型。
这几个能力放在 Dots 场景里恰好是刚需。节点画布本身就是高度定制的东西,前端不能完全依赖现成聊天组件;模型需要操作节点数据,必须有工具调用;用户反复编辑节点之后,Agent 得记住当前工作区的状态。CopilotKit 不是万能的,但它把最底层的地基打好了,OpenDots 只需要在它上面盖自己的楼层。
2. OpenDots 的架构怎么做:以 CopilotKit 为底座的分层设计
2.1 最小可用架构:三层分离,各干各的
我第一次搭的时候,犯了一个典型错误:想着把 CopilotKit 的聊天组件整个塞进页面,然后花大量时间去改样式,结果发现画布和聊天组件的交互逻辑互相打架。后来我重新按职责把系统切成三层,思路立刻清晰了。
第一层是展示层。负责渲染节点画布、节点详情面板、连接线,以及一个低侵入的聊天输入框。这一层全部是 React 组件,直接用 CopilotKit 暴露的 Hooks 拿消息和状态,不一定要用它的整套 UI 组件。
第二层是协调层。跑在 CopilotRuntime 上,负责连接前端和模型,处理流式输出,维护 Agent 状态,注册工具调用。这一层是 OpenDots 的核心,所有 AI 相关的能力都收拢在这里。
第三层是数据层。存储节点数据、连接关系、操作日志。初期用一个 JSON 文件或者 SQLite 就能跑,后期要支持多人协作再换 PostgreSQL。重点是:数据层和 AI 逻辑完全解耦,模型只是通过工具操作数据,不直接读写数据库。
语言上我推荐直接上 TypeScript,因为节点类型、连接关系这些数据结构在开发期就能检查出来,避免运行时才发现某个字段拼错了。
2.2 前端为什么选 Hooks 而不是整套开箱组件
CopilotKit 确实提供了像 CopilotSidebar、CopilotPopup 这类开箱即用的组件,接入很快,但 OpenDots 的场景不太适合。原因很简单:Dots 的交互核心是画布,不是对话框。开箱组件默认带了聊天气泡、输入条、折叠面板,这些 UI 在点阵工作流里非常突兀。
所以我最终使用的是useCoAgent和useCopilotChat这一层。拿useCoAgent来说,它可以让你像管理本地状态一样管理 Agent 的运行状态:
import { useCoAgent } from "@copilotkit/react-core"; import type { AgentState } from "../types/agent-state"; export function DotsCanvas() { const { state, setState, runAgent } = useCoAgent<AgentState>({ name: "dots-agent", initialState: { dots: [], connections: [], focusId: null, }, }); // 这里 state.dots 就是当前工作区的全部节点 // 画布组件只需要读 state.dots 渲染,修改时调用 setState }这个做法的好处有两个。第一,AI 的“大脑”和画布的“手”是分离的。画布负责展示和交互,Agent 只在用户按下“整理”按钮时才运行。第二,状态是单源的。前端画布、Agent 认知、数据库三方同步的是同一份结构,不会出现画布上删了一个点,Agent 还拿着旧数据分析的情况。
如果要保留聊天气泡式的输入窗口,也可以再用useCopilotChat单独挂一个窄条输入区。但我的建议是:输入方式可以后期再加,第一版先把“节点编辑 + AI 整理”这条主链路跑通。
2.3 模型接入不锁死任何人
OpenDots 对标的是闭源 Dots,但如果自建的代价是被迫绑定某一家模型厂商,那就失去意义了。CopilotRuntime 的 adapter 模式正好解决这个问题。它把模型适配层单独抽出来,只要这个模型暴露的是兼容接口,就可以随时替换。
以当前常用版本为例,运行时大概长这样:
import { CopilotRuntime } from "@copilotkit/runtime"; import { OpenAIAdapter } from "@copilotkit/runtime-openai"; export const runtime = new CopilotRuntime({ adapter: new OpenAIAdapter({ model: process.env.DOTS_MODEL_NAME ?? "gpt-4o", apiKey: process.env.DOTS_MODEL_API_KEY, }), remoteActions: [...], });模型名和 API Key 全部走环境变量,这样不同部署环境可以自由切换:内网部署用本地模型,云端 Demo 用商用模型,团队测试用便宜的小模型。这正是开源替代最踏实的地方——模型市场风向怎么变,你都不用跟着换船。
3. 核心流程落地:把“零散点”变成“结构化成果”的关键实现
3.1 给一个“点”建立合适的数据模型
所有看起来玄幻的 AI 产品,底层都是老实的数据结构。OpenDots 里“点”这个设计,我最终拆成下面几个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 节点唯一标识,画布渲染的 key |
| type | idea / task / decision / evidence | 节点类型,决定它后续被归类到哪个逻辑分区 |
| content | string | 节点正文,最大长度建议限制在 2000 字符以内 |
| status | open / in-progress / done | 任务类节点的完成状态 |
| createdAt、updatedAt | string | 时间戳,用于排序和审计 |
| tagIds | string[] | 可选的标签集合,不强制 |
| relatedIds | string[] | 与当前节点有关联的其他节点 ID |
这里有一个经验之谈:relatedIds 不要设计成双向同步。新建一个连接时,只更新源节点的 relatedIds,目标节点不强制写反向字段。读取的时候用一次查询把所有连接关系拉出来,在内存里补全反向索引。否则每次连线要在数据库里更新两个地方,很容易出现只写了一半的脏数据。
3.2 Agent 编排:一次会话里跑完分类、关联和行动项提取
OpenDots 的价值如果只是“把文字变成节点”,那换个富文本编辑器就够了。真正核心的地方在于:AI 能不能读懂节点之间的关系,并把它们组织成一个可执行的行动计划。我在 Agent 提示词和工具调用上折腾了很久,最终的编排思路可以概括成三步走。
第一步,模型先读取当前工作区里的全部节点。这一阶段我通过useCoAgent的上下文注入实现,让 Agent 看到一份精简的节点清单。第二步,Agent 调用工具对节点做分类和关联。我定义了四个工具:createDot、linkDots、updateDotStatus、archiveDots。每一个工具都只做一件事,参数是明确的 JSON 结构。第三步,模型生成一个“整理建议”的节点,汇总它做的事情,并且列出还没有解决的行动项。
在 CopilotKit 里,工具注册逻辑大致是这样:
import { CopilotKit, useCoAgent } from "@copilotkit/react-core"; // 定义可供 Agent 调用的动作 const actions = [ { name: "linkDots", description: "在指定的两个节点之间建立关联", parameters: { type: "object", properties: { sourceId: { type: "string" }, targetId: { type: "string" }, reason: { type: "string", description: "建立关联的理由" }, }, required: ["sourceId", "targetId"], }, handler: async ({ sourceId, targetId, reason }) => { // 写数据层并返回新的连接信息 return { ok: true, sourceId, targetId, reason }; }, }, ];提示词里需要特别强调的一句话是:“不要急于给出总结,先为每个节点完成分类,再寻找跨节点的关联,最后才生成行动项。”没有这句话,模型往往会直接输出一段漂亮的总结,而节点的结构依然是乱的。
3.3 流式输出与前端状态同步的两个大坑
这一步是我实际开发中花时间最多的部分,远比接入模型要折磨人。第一个坑是状态来源不一致。CopilotKit 的 Agent 状态在前端和运行时之间有一个同步协议,但我一开始在画布组件里自己维护了一份useState,结果 Agent 修改节点之后,画布没有刷新;反过来前端拖拽节点,Agent 那边又不知道。原因就是“一份状态,两个副本”。
正确的做法是:所有的节点增删改,都走setState更新 Agent 状态,画布组件只从 Agent 状态里派生渲染数据。听起来很抽象,其实就是把前端当“哑巴组件”,所有状态变更都集中在useCoAgent返回的state上。这样不管修改来自用户拖拽,还是来自模型工具调用,渲染层都是同一份数据源。
第二个坑是工具调用的重复执行。模型生成一次响应时,如果流式传输过程中前端重连,工具可能被执行两次。解决方式是给每个工具动作加一个幂等字段,例如前端生成一个clientMessageId,随参数一起发给服务端,服务端在写入前检查这个 ID 是否已经处理过。这个细节不写进代码里,上线之后早晚会出问题。
4. OpenDots 真正好用,取决于这几个容易被忽视的设计
4.1 上下文窗口做“瘦身”,不做“堆料”
很多人以为 Agent 能力越强,就越应该把所有节点一次性丢给它分析。实测下来,当节点数量超过 50 个时,模型注意力会被稀释,反而分不出哪些是重要决策点。我在 OpenDots 里做了一套上下文裁剪方案:
- 用户当前选中的节点:完整内容直接注入。
- 选中节点直接相连的节点:完整内容注入。
- 其他节点:只注入标题和类型,不注入正文。
- 全工作区总量超过 100 个节点时:对非关联节点做摘要压缩再注入。
这样既保证模型关注的子图信息充分,又把单次上下文的 token 消耗压在一个可控范围。整理全画布这种重型操作交给另一个独立 Agent 流程,避免主流程被长上下文拖慢。
4.2 工具调用要“可视化”,不要当黑箱
模型调用工具的时候,用户界面如果毫无反馈,体验会非常像“转圈然后直接出结果”。OpenDots 的做法是把每次工具调用变成一条可追踪的事件,追加到一个日志面板里,同时让相关节点出现短暂的闪烁效果。
比如模型执行了linkDots("n1", "n2", "都涉及登录模块"),前端会在连接线建立的同时,在这条连接上显示一行小字“都涉及登录模块”。这个透明机制对用户理解 AI 为什么这么做极有帮助,而且调试阶段可以直接从日志面板看到模型到底在按什么逻辑工作。我用下来发现,这个功能比长期保存聊天记录要有价值得多。
4.3 权限边界:开源替代最容易忽视的安全问题
很多自建 AI 工具的人,注意力全放在功能上,完全忘记权限控制。OpenDots 定位在内部工具场景,所以至少要做三件事。
第一,前端、运行时、数据层三个入口都做鉴权,不要只挡一层就以为安全。第二,Agent 能访问的数据必须限定在当前用户的工作区,提示词里哪怕写了“读取全部数据”,工具层也要拦截。第三,外部模型请求时,关闭日志透传,不要把用户节点正文直接发进模型服务商的日志系统。内部 Demo 阶段可能无所谓,正式部署哪怕只有十个人用,也应该把这三点补上。
5. 开源替代不是白嫖:聊聊真实成本与选型边界
5.1 这笔账要算清楚
很多人看到“开源”两个字就默认免费,实际上开源替代省掉的主要是“订阅费和功能限制”,但引入的是“运行成本和维护成本”。我假设三种方案来算一笔账。
| 方案 | 前期成本 | 运行成本 | 自由度 | 适合场景 |
|---|---|---|---|---|
| 直接用闭源 Dots | 低,开账号就行 | 固定订阅费 | 低,功能由厂商定义 | 个人效率、轻量记录 |
| OpenDots + 商用模型 API | 中等,需要服务器和前端开发 | 按 token 付费,费用与使用量正相关 | 高,数据和流程完全可定制 | 中小团队内部工具、私有数据 |
| OpenDots + 本地模型 | 较高,需要 GPU 服务器 | 主要是硬件折旧和电费 | 极高,数据完全不出网 | 数据敏感场景、离线环境 |
我实际跑下来,商用 API 方案在小团队(五到十人)的规模下,月成本通常不到订阅制工具的几倍,但换来的是数据掌控和定制能力。如果节点量很少,成本甚至能压到很低。本地模型方案里,模型精度和响应速度是主要矛盾,需要一个懂模型部署的人来维护,不是搭完就完事了。
5.2 什么情况建议别自建
废话不多说,自建 OpenDots 适合解决的问题是:数据敏感、需要深度定制交互、要对接内部系统、希望保留模型选择自由。如果你的情况是只想快速整理个人点子,那就老老实实买现成工具;如果团队里没有人能维护前端和运行时,自建的成本会直接抵消掉它带来的收益。
我个人觉得,开源替代最实在的价值不是“省钱”,而是“给你留下了一整套可以改的代码”。今天可以换模型,明天可以加企业微信推送,后天可以把节点数据导入内部知识库——这些玩法在一个闭源 SaaS 页面里基本不可能实现。
最后再分享一个小技巧:给 Agent 定义的每个工具函数,命名时尽量用“动词+宾语”的清晰结构,比如linkDots、archiveDots、splitDot,而不是模糊的process、update、handle。我实测下来,模型对动作明确的函数名调用准确率明显更高,这比在提示词里反复强调“你要先调用工具”有用得多。OpenDots 目前还是一个可以继续打磨的模板,但它已经把 Dots 类产品最核心的那层窗户纸捅破了——剩下的,就是你想要怎么让它为你工作。