CHAPTER 03 · STAGE 01 · 建立项目地图
读源码前需要懂的 TypeScript 与 Node.js
阅读这个仓库需要的不是完整 TypeScript 语言知识,而是一组反复出现的结构:ESM 导入导出、接口与类型、联合类型、泛型、声明合并、异步迭代、AbortSignal、类继承和 disposer。不需要先成为 TypeScript 专家
阅读这个仓库需要的不是完整 TypeScript 语言知识,而是一组反复出现的结构:ESM 导入导出、接口与类型、联合类型、泛型、声明合并、异步迭代、AbortSignal、类继承和 disposer。
先能识别这些结构的作用,再逐步学习细节。遇到复杂类型时,优先问“它约束了谁能传什么”,而不是尝试在脑中完成 TypeScript 编译器的所有推导。
ESM:文件之间怎样连接
仓库使用 ESM。常见形式如下:
import { Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
export class AgentLoop { ... }
普通 import 会在运行时加载模块。import type 只给 TypeScript 检查类型,编译后会消失。看到 import type 时,不要把它误认为运行时依赖。
仓库内的本地相对导入带 .ts 后缀,包之间使用包名。源码通过 TypeScript paths 解析,构建产物通过 package exports 解析。初学阶段只需要知道:测试通常直接运行源码,而发布路径还要验证构建后的 lib/。
interface 和 type
两者都能描述数据,但仓库常利用它们的不同能力。
interface 经常用于可扩展映射和 Service API。例如插件可以通过 declaration merging 给一个已有接口增加事件成员。type 经常用于联合、映射结果或别名,例如一个结果只能是成功、失败或取消中的一种。
阅读时把它们翻译成问题:
interface ToolDefinition:一个工具必须提供哪些字段?type FinishReason = ...:模型结束原因允许哪些情况?type SessionEvent<T> = ...:不同事件类型与各自 data 怎样绑定?
Discriminated Union:通过标签判断具体分支
仓库大量使用带标签的联合类型:
{ kind: 'allow' }
{ kind: 'deny', reason: string }
{ kind: 'ask', question: string }
代码通过 switch (decision.kind) 或 if (decision.kind === 'deny') 缩小类型。这比多个可选字段安全,因为每个分支能明确规定自己需要的数据。
看到 assertNever 时,它通常表示这是一个封闭联合:新增分支后,编译器会提醒所有 switch 更新。看到带 default 的事件 switch 时,往往表示联合允许其他插件通过声明合并扩展,不能假设已知分支就是全部。
泛型:把某个类型当参数
SessionEvent<T> 中的 T 可以理解成“具体事件名”。当 T 是 'tool/result' 时,TypeScript 能推导对应的 data 字段。
初学时不用展开复杂泛型,只做两步:找到泛型参数代表的概念;找到调用处传入的具体类型。如果调用处没有显式写出,观察函数参数和返回值让编译器推导了什么。
Declaration Merging:多个包共同扩展同一张类型表
Cordis Context 和事件表需要让插件扩展。一个插件可能声明:
declare module '@deepseek-ai/cordis' {
interface Context {
myService: MyService
}
interface Events {
'my/event'(payload: Payload): void
}
}
这段代码只扩展 TypeScript 对 Context 和 Events 的认识,不会自动创建 Service,也不会自动发出事件。插件仍要在运行时注册 Service 或监听器。
这是最容易误解的地方:类型声明告诉编译器“允许存在”,插件 effect 才让它“实际存在”。
类、继承与 Service
许多核心服务继承 Cordis 的 Service。类表达有状态的运行时能力,Cordis 负责把实例挂到 Context 并管理生命周期。
看到 class LlmRuntime extends Service 时,先看三件事:它使用哪个 ctx key;构造时需要哪些 Service;公开方法是注册 Provider、执行请求还是查询状态。不要从私有辅助函数开始读。
抽象 Service 常使用 abstract 方法规定 Provider 必须实现的 API。例如持久化能力定义“加载、追加、刷新”等操作,JSONL 和 SQLite 后端分别实现。
Promise、async/await 与 AsyncIterable
工具执行通常返回 Promise,因为文件、网络和进程操作需要等待。LLM 流式输出则常表现为 AsyncIterable:调用方可以用 for await 持续接收 chunk。
区分两个时间点:函数已经返回一个异步对象,不等于工作已经完成;Promise settle 或异步迭代结束,才表示调用的异步生命周期完成。
这也是 teardown 复杂的原因。插件卸载时不能只删除注册表条目,还要等待已开始的工作停止并收敛。
AbortSignal:协作式取消
AbortSignal 不是强制终止线程。调用方发出取消信号,被调用的工具、网络请求或子进程需要观察或转发这个信号,才能尽快停止。
因此 ToolDefinition 的执行函数不仅接收参数,还接收包含 signal 的执行上下文。一个忽略 signal 的同进程函数可能继续运行;框架只能等待它结束,不能安全地从外部杀死任意 JavaScript。
阅读取消代码时问:谁创建 signal;谁转发 signal;取消后谁负责等待资源完全停止;Session 最终记录什么结束原因。
disposer、ctx.effect() 与资源所有权
注册工具、事件或 Provider 通常会返回一个 disposer。调用 disposer 后,注册应从系统中消失。
ctx.effect() 把“建立资源”和“释放资源”绑定到插件生命周期。插件卸载或热重载时,Cordis 能按生命周期撤销注册。如果一个插件直接修改全局数组却没有 disposer,旧注册可能在重载后残留。
把 disposer 理解为资源所有权证明:创建者必须明确怎样撤销自己建立的状态。
Branded ID:看起来是字符串,但不能混用
SessionId、MessageId、CallId 等在运行时可能都是字符串,但类型系统给它们不同品牌。这样就不能意外把 MessageId 传给需要 SessionId 的函数。
边界处通常有构造函数,例如 SessionId(raw)。品牌不是加密或运行时安全机制,而是防止同进程 TypeScript 代码把不同领域的标识符混用。
Schema 校验与静态类型不是一回事
TypeScript 类型在运行时不存在。来自 YAML、JSON、模型 Tool Call、磁盘日志或网络的数据必须在运行时校验。仓库使用 schema 库描述配置和边界数据。
同进程、已经经过 TypeScript 检查的对象通常不重复做敌对输入校验;真正跨越配置、模型、文件、进程或 wire 的数据才需要运行时检查。
读一个陌生类型的步骤
- 找它是输入、输出、事件 data 还是持久化记录。
- 找创建它的生产者。
- 找至少一个消费者。
- 看标签字段怎样区分分支。
- 看对象是否会跨越 JSON、磁盘、进程或模型边界。
- 最后再研究泛型和映射类型细节。
本篇练习
- 在
packages/core/agent/src/runtime-types.ts找到AgentStatus、PreStepDecision和Agent,判断哪些是联合类型、哪些是接口。 - 在
packages/llm/llm/src/brand.ts找到 MessageId 与 CallId,解释品牌解决的问题。 - 在
packages/core/agent/src/index.ts找一个公开的异步创建方法,指出谁拥有返回的 handle。 - 用自己的话解释 declaration merging 为什么不能代替运行时插件注册。
验收标准:遇到一个复杂类型时,能先说出它约束的边界和生产者/消费者,而不是只说“这是 TypeScript 高级类型”。