Files
dstalk/docs/explanation/architecture.md
XiuChengWu 5766938524
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Wave 5+6: plugin ABI hardening, build modernization, ABI/security docs
Wave 5 (9 parallel agents):
- W1.1 atomic diag callback + DLL handle release on shutdown (lin)
- W2.1 unify cross-DLL heap discipline (host->alloc/free/strdup) (chen)
- W2.2 secure_zero api_key on shutdown for deepseek/anthropic (cao)
- W3 CMake modernization: target-based cxx_std_20, dstalk_boost_config
  INTERFACE lib, root-level RUNTIME_OUTPUT_DIRECTORY (hu)
- W4 GitHub Actions CI with dynamic Linux/Windows matrix (ma)
- W5.1 SSE buffer_body to cut peak memory ~67% on 32K streams (zhou)
- W6.1 LSP JSON-RPC frame parser hardened against header reordering (sun)
- W7 smoke test: copy plugin DLLs post-build + Boost.JSON src.hpp fix
  for full 9-plugin load coverage (wang)
- W8.1 README slimmed 398->92, Diataxis docs/ skeleton (deng)

Wave 6 (6 parallel agents):
- W9.1 docs/explanation: architecture + plugin-lifecycle (deng)
- W9.3 log credential leak audit (0 vulns, audit trail in
  docs/explanation/security-logging.md) (cao)
- W9.4 docs/reference/plugin-abi.md - 7-point ABI contract (lin)
- W9.6 CLI /history command + status integration (zhao)
- W9.8 plugin_loader fault tolerance: per-plugin failure no longer
  aborts dstalk_init (huang)
- W9.10 host_api unit tests: tests/host_api_test.cpp, 8 cases (liu)

CEO oversight (preexisting bugs fixed during Wave 5 verification):
- lsp_plugin.cpp:449 forward decl mismatch (int vs void)
- tools_plugin.cpp:109 missing forward decl

Multi-agent collaboration framework:
- agents/WORKFLOW.md: 6-stage protocol, two-tier governance,
  prompt template, technical constraints registry

Build: cmake --build 0 error / 0 warning. Tests: 2/2 100% pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 05:39:10 +08:00

7.3 KiB
Raw Blame History

dstalk 架构哲学

本文属于: Explanation -- 解释"为什么这么设计",建立心智模型。 不涉及具体 API 签名或配置字段,那属于 Reference 的职责。


为什么是插件架构?

dstalk 选择的不是单体架构,而是以 C ABI 为边界的插件架构。这是几条需求推导出的必然结果:

  1. AI 后端会变。DeepSeek / 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/ 目录、加载 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 查找它依赖的服务(例如 deepseek 插件查询 httpconfig)。
  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.deepseek""http""file_io")。
  • 版本号(消费者可以要求 min_version)。

Service Registry 解决什么问题?

核心问题是 耦合方向。如果不使用注册表deepseek 插件就得直接知道 http 插件的符号,通过 dlsym 或头文件耦合。换成注册表后:

  • 提供者说:"我注册一个名为 http 的 vtable"。
  • 消费者说:"给我一个名为 http、版本 >= 1 的 vtable"。
  • Host 做中间人查表。

效果: 你可以用任意方式实现 http 服务——用 libcurl、用 WinHTTP、用 mock——deepseek 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。


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 边界。