- New plugins_upper/ai_common/ static library: shared PluginConfig, ToolCallAccum, StreamContext, secure_zero, extract_host_port, serialize_tool_calls, free_chat_result - Refactored openai/anthropic plugins to use dstalk_ai:: namespace from ai_common - Fixed anthropic g_config raw pointer → std::atomic (data race) - Added SSE parse error counter with threshold abort (kMaxSseParseErrors=5) - Fixed missing closing brace in both plugins' error-body catch block - Updated test targets: ai_common include path + link, using namespace dstalk_ai - plugin_loader_test: added stub_unreg + service_registry.cpp for unregister_service - Includes pre-existing uncommitted changes from prior waves Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.4 KiB
dstalk 架构哲学
本文属于: Explanation -- 解释"为什么这么设计",建立心智模型。 不涉及具体 API 签名或配置字段,那属于 Reference 的职责。
为什么是插件架构?
dstalk 选择的不是单体架构,而是以 C ABI 为边界的插件架构。这是几条需求推导出的必然结果:
-
AI 后端会变。OpenAI-compatible / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A,明天切 B,后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
-
能力会增长。LSP 集成、文件管理、会话持久化、工具调用——这些能力不是 CLI 启动时必须加载的。使用者可能只需要聊天,不需要 LSP。插件架构让能力按需加载,启动更快,内存更省。
-
插件作者不是核心团队。第三方应该能用自己的编译器、自己的 C++ 标准库版本编写插件,而不必须链接 dstalk_core 的静态库。这要求 ABI 稳定。C ABI 是唯一具有跨编译器二进制兼容性的选择。
一句话心智模型: 不要想象一个胖二进制把所有功能静态链接在一起;想象一个内核 (host) + 一圈可插拔的服务单元 (plugin),内核只负责编排,不负责实现。
三层模型
dstalk 的架构由 3 层组成。从上到下看,每一层依赖下一层提供的抽象:
┌────────────────────────────────────────────┐
│ Plugin (DLL) │
│ 实现具体能力:AI 聊天、文件读写、LSP... │
│ 通过服务注册表向其他插件暴露自己的功能 │
├────────────────────────────────────────────┤
│ Host (dstalk_core) │
│ 拥有事件总线、服务注册表、插件加载器、配置 │
│ 提供一个 dstalk_host_api_t 接口给所有插件 │
├────────────────────────────────────────────┤
│ Service (C vtable) │
│ 抽象接口:ai / http / session / file_io... │
│ 消费者通过 query_service 查找,不看实现者是谁│
└────────────────────────────────────────────┘
Host 层
Host 是 dstalk 的 编排内核。它自己不做 AI 推理,不读写文件,不管理会话。
Host 拥有四样东西:
- 配置存储 (ConfigStore): 管理
config.toml的加载和键值查询。 - 事件总线 (EventBus): 插件间松耦合通信的唯一通道。
- 服务注册表 (ServiceRegistry): 按名称 + 版本号存储和查找服务 vtable。
- 插件加载器 (PluginLoader): 扫描
plugins_base/、plugins_middle/、plugins_upper/三层目录、加载 DLL、按依赖拓扑排序后调用初始化。
Host 启动时,按严格顺序执行:
- 分配上述四个组件。
- 加载
config.toml(如果提供了路径)。 - 扫描
plugin_dir目录下所有.dll/.so文件。 - 调用每个插件的
dstalk_plugin_init()获取插件描述符。 - 按依赖拓扑排序执行
on_init。
Plugin 层
每个插件是一个独立的动态库,导出一个唯一入口函数 dstalk_plugin_init()。
该入口返回一个 dstalk_plugin_info_t,声明了:
- 插件名称(依赖解析时的唯一标识)。
- 依赖列表(必须先于本插件初始化的其他插件名)。
- 生命周期回调(
on_init/on_shutdown)。 - 可选的事件回调(
on_event)。
插件在 on_init 中做三件事:
- 保存
host_api指针,这是它此后访问一切 Host 能力的唯一通道。 - 通过
host_api->query_service查找它依赖的服务(例如 openai 插件查询http和config)。 - 通过
host_api->register_service向注册表注册自己提供的服务 vtable。
关键设计:插件之间不直接链接。插件 A 不知道插件 B 是否在当前进程中。A 只对 Host 说"我需要名为 http 的服务",Host 从注册表里找出那个 vtable,把指针交给 A。
Service 层
Service 是纯 C vtable——一个包含函数指针的结构体。以 dstalk_ai_service_t 为例:
struct dstalk_ai_service_t {
int (*configure)(...);
dstalk_chat_result_t (*chat)(...);
dstalk_chat_result_t (*chat_stream)(...);
void (*free_result)(dstalk_chat_result_t*);
};
每种服务都有一个预定义的 vtable 结构体(定义在 dstalk_services.h)。第三方也可以扩展自己的服务 vtable,版本号随注册一起提供,允许消费者做最低版本检查。
服务注册采用 name + version 两要素:
- 全局唯一名称(如
"ai_openai"、"http"、"file_io")。 - 版本号(消费者可以要求
min_version)。
Service Registry 解决什么问题?
核心问题是 耦合方向。如果不使用注册表,openai 插件就得直接知道 http 插件的符号,通过 dlsym 或头文件耦合。换成注册表后:
- 提供者说:"我注册一个名为
http的 vtable"。 - 消费者说:"给我一个名为
http、版本 >= 1 的 vtable"。 - Host 做中间人查表。
效果: 你可以用任意方式实现 http 服务——用 libcurl、用 WinHTTP、用 mock——openai 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。
Event Bus 解决什么问题?
服务注册表解决的是调用链:"我需要你,你给我提供服务"。但架构里还有另一类通信需求——通知链:"某件事发生了,关心的人自行处理"。
举例:
- 会话清空了 (
DSTALK_EVENT_SESSION_CLEAR)——所有关心会话状态的插件应当知道。 - 配置更新了 (
DSTALK_EVENT_CONFIG_CHANGED)——读取了配置的插件应当刷新。 - 插件加载/卸载——其他插件可能需要重新查询依赖。
Event Bus 把这些需求抽象为发布-订阅:
- 发布者不知道谁在听。
- 订阅者不知道谁在发布。
- 发布者和订阅者通过事件类型编号耦合(如
DSTALK_EVENT_SESSION_CLEAR = 2)。
效果: 新写一个监控插件,只需 event_subscribe 即可收到所有感兴趣的事件,不需要修改任何现有代码。
为什么所有插件用 C ABI?
C++ 没有稳定的 ABI。不同编译器(MSVC / GCC / Clang)、不同编译器版本、不同标准库版本(libstdc++ vs libc++)之间,C++ 类的内存布局、虚函数表布局、异常展开机制都不保证兼容。
C ABI 的保证:
- 编译单元之间传递的只有结构体和函数指针。
- 结构体布局确定(
dstalk_plugin_info_t的字段顺序不会变)。 - 函数调用约定确定(
extern "C"关闭 name mangling)。 - 所有内存分配通过 Host 提供的
alloc/free,而非各自调用malloc/free(不同 DLL 使用不同运行时库的malloc会崩溃)。
一句话心智模型: C ABI 是唯一能保证"用 MSVC 编译的插件在 GCC 编译的 host 中运行"的接口层。代价是只能传递纯数据结构和函数指针,不能使用异常、重载或模板跨 DLL 边界。