- 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>
139 lines
7.4 KiB
Markdown
139 lines
7.4 KiB
Markdown
# dstalk 架构哲学
|
||
|
||
> **本文属于**: Explanation -- 解释"为什么这么设计",建立心智模型。
|
||
> 不涉及具体 API 签名或配置字段,那属于 Reference 的职责。
|
||
|
||
---
|
||
|
||
## 为什么是插件架构?
|
||
|
||
dstalk 选择的不是单体架构,而是**以 C ABI 为边界的插件架构**。这是几条需求推导出的必然结果:
|
||
|
||
1. **AI 后端会变**。OpenAI-compatible / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A,明天切 B,后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
|
||
|
||
2. **能力会增长**。LSP 集成、文件管理、会话持久化、工具调用——这些能力不是 CLI 启动时必须加载的。使用者可能只需要聊天,不需要 LSP。插件架构让能力按需加载,启动更快,内存更省。
|
||
|
||
3. **插件作者不是核心团队**。第三方应该能用自己的编译器、自己的 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 启动时,按严格顺序执行:
|
||
1. 分配上述四个组件。
|
||
2. 加载 `config.toml`(如果提供了路径)。
|
||
3. 扫描 `plugin_dir` 目录下所有 `.dll` / `.so` 文件。
|
||
4. 调用每个插件的 `dstalk_plugin_init()` 获取插件描述符。
|
||
5. 按依赖拓扑排序执行 `on_init`。
|
||
|
||
### Plugin 层
|
||
|
||
每个插件是一个**独立的动态库**,导出一个唯一入口函数 `dstalk_plugin_init()`。
|
||
|
||
该入口返回一个 `dstalk_plugin_info_t`,声明了:
|
||
- 插件名称(依赖解析时的唯一标识)。
|
||
- 依赖列表(必须先于本插件初始化的其他插件名)。
|
||
- 生命周期回调(`on_init` / `on_shutdown`)。
|
||
- 可选的事件回调(`on_event`)。
|
||
|
||
插件在 `on_init` 中做三件事:
|
||
1. 保存 `host_api` 指针,这是它此后访问一切 Host 能力的唯一通道。
|
||
2. 通过 `host_api->query_service` 查找它依赖的服务(例如 openai 插件查询 `http` 和 `config`)。
|
||
3. 通过 `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 边界。
|