DeepSeeker-Code源码导读10-代码智能tsHost

读懂 tsHost:几十 MB 的 TypeScript 依赖,怎么按需启用又不拖垮性能

这篇讲什么

前面九篇的工具,多是文件读写、命令执行、第三方接入。这篇讲一类特殊的工具------代码智能:符号大纲(view_symbol_outline)、类型诊断(get_diagnostics)、定义跳转(goto_definition)。这些能力不是 agent 自己写逻辑能实现的,得靠 TypeScript 官方的 LanguageService------一个能理解类型、解析 import、做语义分析的引擎。

但 TypeScript 这个引擎是个几十 MB 的重型依赖。问题来了:不是每个用户的每个场景都需要代码智能------有人用 VSCode(自带 typescript),有人用 CLI(发布版未必打包 typescript),有人改的是纯 JS 项目。如果一启动就把这个几十 MB 的大家伙全量加载,启动慢、内存涨、没代码智能需求的人白白背锅。

这篇拆 tsHost.ts,一个 200 行的共享基础设施。核心是两个工程问题:重型依赖怎么按需启用 (有就用、没有就消失),以及 TypeScript 全同步协议下怎么做缓存 (既反映磁盘当前态,又不重复算)。读完你会看到,"接一个重型依赖"远不只是 import,而是一整套惰性加载、缓存网关、实例复用的工程。

一、先看全貌:什么工具用 tsHost、什么时候触发

读这篇的钥匙,是下面这张"消费者→tsHost 能力→触发时机"表。tsHost 是共享基础设施,被两类代码智能工具消费:

消费工具 用的 tsHost 能力 解决什么 触发时机
view_symbol_outline AST(SourceFile 解析) 给模型文件的结构骨架(类/函数/导出) 模型要看大文件结构时
get_diagnostics 类型检查器(SemanticDiagnosts) 编译/类型错误,给模型精确报错 模型查"这文件有没有错"时
goto_definition 类型检查器(定义查找) 符号定义在哪,给模型精确跳转 模型要追"这个符号从哪来"时

注意最后一列------这些工具不是常驻的,是模型主动调用时才触发 。而它们能不能被模型调用,又取决于一个前置条件:本机有没有 typescript 模块。这就引出 tsHost 最核心的设计------惰性加载。

拿几个场景盘活:

  • 场景 A:用户用 VSCode 扩展,装了 typescript。 模型调 get_diagnostics,tsHost 的 getTs() 惰性 import 成功,建 LanguageService,返回类型诊断。工具正常工作。

  • 场景 B:用户用 CLI 发布版,没打包 typescript。 getTs() import 失败返回 null。但模型根本看不到 get_diagnostics 这个工具 ------因为它的 validateEnvironment 返回 false,工具在喂给模型前就被剔除了(第 6 篇讲过这个字段)。没有 typescript,连"有这个工具"模型都不知道,不会无效调用。

  • 场景 C:模型连续查同一个项目的 5 个文件的诊断。 第一次建 LanguageService,后 4 次复用同一个实例(LS 实例按项目缓存)。5 次调用只付出一次建实例的成本。

  • 场景 D:模型查了一个文件,然后你手动改了这个文件,模型再查。 tsHost 的 mtime 网关发现 mtime 变了,重读文件、版本号自增,LanguageService 拿到最新内容。反映磁盘当前态,不拿陈旧缓存。

理解了这张表和这些场景,再去看 tsHost.ts,三个核心模块就清晰了:惰性加载(getTs)、全同步快照缓存(createProjectHost)、实例缓存去重(getLanguageService)。下面逐个拆。

二、惰性加载:让重型依赖"有就用、没有就消失"

type-only import:编译期剥离

文件第一行就有讲究(:15):

ts 复制代码
import type * as ts from "typescript";   // type-only------esbuild 编译期剥离,运行时不 resolve

import type 只引入类型信息,esbuild 打包时会整行剥离 ,运行时根本不 resolve 这个模块。为什么这么谨慎?因为 CLI 发布版经 esbuild 打包后不发布 typescript,如果用普通的 import * as ts(顶层静态 import),npm 全局安装时启动就会因为找不到 typescript 而崩------还没等到惰性加载那一步,进程就挂了。type-only 保留了类型检查的好处,又不产生运行时依赖。

getTs:运行时惰性 import + 缺失返回 null

真正加载 typescript 的地方是 getTs:27):

ts 复制代码
let _ts: TsModule | null = null;
let _tsTried = false;
export const getTs = async () => {
    if (_tsTried) return _ts;   // 只试一次,失败永久 null(不反复试拖慢)
    _tsTried = true;
    try { _ts = await import("typescript"); } catch { _ts = null; }
    return _ts;
};

三个细节:异步 import (不阻塞启动)、try/catch 吞掉失败 (缺失返回 null 而非抛错)、只试一次_tsTried 标志,避免反复 import 拖慢)。这个设计让 typescript 的加载变成"用到才加载、没有就当没有"------完美契合"重型依赖按需启用"。

validateEnvironment:工具层自隐藏

tsHost 只负责"加载 typescript 并返回 null if 没有"。真正让工具从模型视野消失的,是工具的 validateEnvironment(第 6 篇的字段)。get_diagnostics 这类工具会这么写:

ts 复制代码
validateEnvironment: async () => (await getTs()) !== null,

本机有 typescript 返回 true,工具暴露给模型;没有返回 false,runAgent 在喂工具表给模型前把它剔除(systemInjections 的 filterByEnvironment)。模型连"有这个工具"都不知道------这比"暴露了再调用时报错"优雅太多。tsHost 的惰性加载 + 工具的 validateEnvironment,两段配合,实现了"没有 typescript 就零成本消失"。

三、全同步协议下的快照缓存

这是 tsHost 最硬核的部分。TypeScript 的 LanguageService 通过 LanguageServiceHost 接口拿文件内容,而这个接口要求全同步getScriptSnapshot 等方法必须同步返回)。但 agent 场景下,文件可能随时被改------你又得反映磁盘当前态。怎么在"全同步"和"实时性"之间兼顾?

mtime 网关:命中缓存则 version 不变

答案在 createProjectHostfresh 函数(:114)------一个 mtime 网关

ts 复制代码
const fresh = (fileName: string): CachedScript | undefined => {
    const st = fsSync.statSync(key);              // 同步取 stat
    const cached = cache.get(key);
    if (cached && cached.mtimeMs === st.mtimeMs) return cached;   // mtime 不变 → 命中缓存
    // mtime 变了 → 重读 + 版本号自增
    const text = fsSync.readFileSync(key, "utf-8");
    const entry = { version: (cached?.version ?? 0) + 1, snapshot: ts.ScriptSnapshot.fromString(text), mtimeMs: st.mtimeMs };
    cache.set(key, entry);
    return entry;
};

关键在 mtime 不变时直接返回缓存,version 不变 。为什么 version 不变这么重要?因为 LanguageService 内部有自己的缓存,它靠 getScriptVersion 判断"这个文件变没变"------version 不变,LS 内部的类型分析缓存就不失效,不用重新算。只有 mtime 真的变了(文件被改),才重读、version 自增,让 LS 知道"这个文件变了,相关分析要重算"。

这个 mtime 网关,同时满足了全同步协议(用 statSync/readFileSync)和缓存效率(mtime 不变命中、version 不变保 LS 内部缓存)。是"协议约束"和"性能需求"的精妙平衡。

模块解析委派 ts.sys

Host 还把模块解析委派给 ts.sys:141):

ts 复制代码
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
realpath: ts.sys.realpath,

为什么用 ts.sys 而不是自己写?因为 TypeScript 解析 import(import x from './foo'import y from 'some-pkg')时要穿 node_modules,按 node 的模块解析规则找文件。ts.sys 是 TypeScript 官方实现的这整套解析逻辑------委派给它,LS 才能正确解析项目里的 import,类型分析才准。这是"不重复造轮子"------TypeScript 自己最懂怎么解析自己的模块。

tsconfig 有界查找

还有一个细节------找 tsconfig 时以工作区所属根为上界:63):

ts 复制代码
const findTsConfigBounded = (ts, absPath) => {
    const root = path.resolve(getContainingRoot(absPath));   // 工作区所属根
    let dir = path.dirname(absPath);
    while (true) {
        if (fsSync.existsSync(path.join(dir, "tsconfig.json"))) return ...;
        if (path.resolve(dir) === root) break;   // 到工作区根为止
        dir = path.dirname(dir);
    }
};

从文件所在目录往上找 tsconfig,但不超过工作区根。为什么?防止捡到工作区外层无关的 monorepo 根 tsconfig------那个 tsconfig 可能配置完全不同(strict、不同的 target),用它会让本项目的诊断结果不对。找不到就回退默认选项。

回退默认选项:宽松,避免误报

找不到 tsconfig 时,用 buildDefaultOptions:44)回退。注意它的策略是故意宽松skipLibCheck: true(不查 .d.ts,省性能/噪声)、strict: false(不给项目"假装"的严格错误)、moduleResolution: Bundler(现代打包器语义,最宽松)。注释说得很直白------回退场景用,不假装严格;真实项目走自身 tsconfig。宁可少报(宽松),也不误报(吓到用户/模型)。

四、LS 实例缓存 + 创建期去重

LanguageService 实例创建很贵(要建 program、解析所有根文件)。所以 tsHost 缓存实例,且用 Promise 去重防并发重复创建。

按 projectRoot::checkJs 缓存

实例按 ${projectRoot}::${checkJs} 做 key 缓存(:167)。同一个项目、同样的 checkJs 设置,复用同一个 LS 实例。checkJs 单独分桶是因为有些查询要强制开 checkJs(对 JS 文件做语义检查),和不开 checkJs 的查询用不同实例------同项目最多两三个实例。

创建期 Promise 去重

并发场景下,两个 SAFE 工具可能同时触发同一个项目的 LS 创建。如果不去重,会建两个实例。tsHost 用 Promise 去重:168):

ts 复制代码
let p = lsCache.get(key);
if (!p) {
    p = (async () => { /* 创建 LS */ })();
    lsCache.set(key, p);   // 把 Promise(而非结果)存进缓存
}
const r = await p;

注意存的是 Promise 而不是结果。第一个调用方创建 Promise 并存入,第二个并发调用方拿到同一个 Promise,await 同一个------创建只发生一次,两个调用方等同一个结果。这是"并发去重"的经典写法。

addRoot 幂等

拿到 LS 后,调用方要 host.addRoot(absPath):186),把查询目标登记为 program 的根文件(确保被纳入分析)。addRoot 往 Set 里加,幂等------同一个文件加多次没副作用。这保证了"每次查询都确保目标在分析范围内",又不会因重复添加出问题。

五、四个设计决策

决策一:重型依赖按需启用

typescript 几十 MB,不是每个场景都需要。getTs 惰性 import + validateEnvironment 自隐藏,让它"有就用、没有就消失"。用户没装 typescript,CLI 启动不会崩、工具表不会多、模型不会无效调用。重型依赖的启用成本,只让真正需要它的人承担。 这是"单人本地工具"定位下的合理取舍------不为所有人都背上几十 MB。

决策二:全同步协议用 mtime 网关兼顾实时与缓存

LS Host 要求全同步,但又要反映磁盘当前态。mtime 网关两全:mtime 不变命中缓存、version 不变保 LS 内部缓存;mtime 变了重读、version 自增让 LS 重算。不轮询、不 fs.watch,靠一次廉价的 statSync 兼顾正确性和性能。

决策三:tsconfig 有界查找 + 宽松回退

找 tsconfig 不超过工作区根(防外层 monorepo 污染),找不到用宽松默认项(skipLibCheck/strict:false,不误报)。宁可少报也不误报------代码智能是辅助,误报会误导模型去"修"本来没错的代码。

决策四:共享基础设施

tsHost 是 view_symbol_outline(AST)和代码导航/诊断工具(类型检查器)的共同底座 。typescript 模块的惰性加载、Host 创建、tsconfig 解析、快照缓存------这些全在 tsHost 里写一次,两类工具共用。如果每个工具自己加载 typescript、自己建 Host,不仅重复,还会建出多个 LS 实例浪费内存。抽共享基础设施,是"DRY"在重型依赖管理上的体现。

六、五个技术难点

难点一:type-only import vs 运行时 import

为什么不能直接 import * as ts from "typescript"?因为 CLI 发布版经 esbuild 打包后不发布 typescript(减体积)。顶层静态 import 在 npm 全局安装时启动即崩------还没进到 agent 主循环,进程就因找不到模块挂了。type-only import 让 esbuild 编译期剥离这行,运行时按需 await import()同一个模块,编译期要类型、运行时要惰性,两种 import 各司其职。

难点二:mtime 网关为什么不用 fs.watch

直觉上,要反映磁盘变化,用 fs.watch 监听文件变更更"实时"。但 fs.watch 在跨平台(尤其 Windows)上不可靠 ------事件丢失、重复触发、子目录不支持。而且 LS Host 是同步协议,fs.watch 的异步回调塞不进同步的 getScriptSnapshot。所以用 statSync 取 mtime 做网关 ------每次查询时同步 stat 一次,mtime 变了才重读。廉价(一次 stat)、可靠(不丢事件,因为每次都查)、契合同步协议。用"查询时校验"代替"事件监听",在同步约束下是更稳的选择。

难点三:LS 实例缓存 key 的设计

为什么 key 是 projectRoot::checkJs 而不是文件路径?因为 LanguageService 是项目级的------一个项目的所有文件共享一个类型分析上下文(同一个 tsconfig、同一个 program)。按文件缓存会建无数个实例(每个文件一个),既浪费又类型分析不准(跨文件引用断开)。按 projectRoot 缓存,同项目复用一个实例,跨文件引用、import 解析才正确。checkJs 单独分桶,是因为开/关 checkJs 会让同一个 JS 文件的分析结果完全不同,必须用不同实例。

难点四:创建期 Promise 去重

并发 SAFE 工具调用(第 7 篇讲过 SAFE 可并发)可能同时触发同项目的 LS 创建。如果不去重,建出两个实例------一个浪费内存,二个两份缓存可能不一致。Promise 去重:第一个创建并存 Promise,第二个拿同一个 Promise await。关键在于缓存的是 Promise(进行中的创建),不是结果(创建完的实例)------这样并发调用方都能在创建完成后立刻拿到结果,而不是第二个发现缓存空又建一个。

难点五:Windows TS1261 大小写伪报

这是个非常隐蔽的 Windows 特有坑(:176 注释)。TypeScript 有个 forceConsistentCasingInFileNames 选项,检查文件名大小写是否一致。在 Windows(大小写不敏感 FS)上,它可能误报 TS1261------为什么?

因为系统里有两条 realpath 路径 :resolveSafePath 用 Node 的非 native realpathSync(保留输入大小写),ts.sys.realpath 用 realpathSync.native(精确磁盘大小写)。同一个文件,两条路径给出的串大小写可能不一样(比如 D:\Code\... vs D:\code\...)。TS 一比对,以为大小写不一致,误报 TS1261------但本项目 tsc 实测 0 错,纯属双路径伪报。

治法两步:一是大小写不敏感 FS 下关掉 forceConsistentCasingInFileNames (大小写差异在不敏感 FS 上本就不是真错误);二是提供 realpathNative:197),统一走 native 取精确大小写。真实的命名大小写 bug 在敏感 FS(Linux)上仍会报(那里不关),只在容易误报的不敏感 FS 上关------按平台精准治理。

七、推荐的源码阅读顺序

  1. 先读文件头注释(:1-14:四个设计要点一目了然,是全文的提纲。
  2. 读 getTs(:27:理解惰性加载 + 只试一次 + 缺陷返回 null。配合第 6 篇的 validateEnvironment,理解"按需启用"全链路。
  3. 读 createProjectHost(:97-149 :重点读 fresh 的 mtime 网关(:114)。理解"全同步协议下怎么兼顾实时和缓存"。
  4. 读 getLanguageService(:160-188:理解实例缓存 key(projectRoot::checkJs)+ Promise 去重 + addRoot 幂等。
  5. 读 forceConsistentCasingInFileNames 那段(:172-178)和 realpathNative(:197:理解 Windows TS1261 伪报的根因和治法。这是个很值得读的"平台特有坑"案例。
  6. 找一个消费方看怎么用 :在 tool/registry/ 里找 get_diagnostics/goto_definition/view_symbol_outline 的实现,看它们怎么 await getTs()getLanguageService、降级处理 null。把 tsHost 的能力对应到具体工具用法。

八、关联:tsHost 在整个系统里的位置

tsHost 是"代码智能"这条线的底座:

  • 按需启用 :和第 1 篇的 load_skill(按需加载 skill)、第 9 篇的 MCP 能力旗标注入一脉相承------用到才加载,没有就消失。tsHost 是这个原则在重型依赖上的极致体现(几十 MB 的 typescript 都能优雅消失)。
  • validateEnvironment:tsHost 提供能力判定(getTs 是否 null),工具的 validateEnvironment 消费它(第 6 篇的字段),systemInjections 的 filterByEnvironment 执行剔除。三段配合,实现"没 typescript 则代码智能工具对模型隐形"。
  • SAFE 并发复用:LS 实例缓存 + Promise 去重,让并发的 SAFE 工具调用(第 7 篇的分波调度)复用同一个 LS 实例------并发查询不会建多个实例。
  • 确定性辅助 :get_diagnostics 给模型精确的编译错误,是第 6 篇 verifyResult 思路的延伸------用 TypeScript 的确定性分析,补模型"看代码"的局限。模型可能看漏类型错误,tsc 不会。

你会看到,代码智能不是"调个 API"那么简单------背后是重型依赖的惰性管理、同步协议的缓存工程、实例复用的并发控制、平台特有坑的精准治理。tsHost 这 200 行,把这些全装进了一个共享底座。

最后

让 agent 理解代码,最直接的想法是"把 TypeScript 装上,调它的 API"。但真正落地要回答一堆问题:没装 typescript 的用户怎么办?全同步协议下怎么反映文件变更?并发查询会不会建一堆实例?Windows 上为什么莫名报 TS1261?这些问题,tsHost 一个个接住了。

读这段源码,最值得带走的是两个思路:一是重型依赖按需启用 ------不是所有用户都需要,那就让它能用时出现、不能用时彻底消失(惰性 import + validateEnvironment + 工具剔除三段式);二是协议约束下的缓存工程------全同步协议逼你用 statSync,那就用 mtime 网关兼顾实时和缓存,不硬上 fs.watch。这两点合起来,就是"几十 MB 的依赖怎么不拖垮一个轻量 agent"的答案。

下一篇,我们读统一注册表框架 registry------看内置工具、MCP 工具、代码智能工具,是怎么被一套统一的注册/发现机制管起来的。

项目源码开源在 github.com/xnk/deepSee... ,文章里提到的文件就是 tsHost.ts,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。

总结

  1. 重型依赖按需启用 :typescript 是几十 MB 的非运行时依赖,getTs 惰性 await import()(缺失返回 null、只试一次),type-only import 让 esbuild 编译期剥离(否则 npm 全局安装启动即崩),配合工具的 validateEnvironment 实现"没 typescript 则代码智能工具对模型隐形";
  2. 全同步协议下的 mtime 网关:LS Host 要求全同步(statSync/readFileSync),mtime 不变命中缓存且 version 不变(LS 内部缓存不失效),mtime 变了重读 + version 自增让 LS 重算------用一次廉价 statSync 兼顾实时性和缓存效率;
  3. LS 实例缓存 + Promise 去重 :按 ${projectRoot}::${checkJs} 缓存(项目级共享类型上下文),创建期存 Promise 而非结果(并发 SAFE 调用复用同一实例),addRoot 幂等确保查询目标在分析范围;
  4. 四个设计决策:重型依赖按需启用、全同步用 mtime 网关、tsconfig 有界查找 + 宽松回退(宁可少报不误报)、共享基础设施(AST 和类型检查共用底座);
  5. 五个技术难点:type-only vs 运行时 import、mtime 网关不用 fs.watch(同步约束下更稳)、实例缓存 key 设计(项目级而非文件级)、Promise 去重(缓存 Promise 不是结果)、Windows TS1261 大小写伪报(双 realpath 路径不一致,不敏感 FS 关 forceConsistentCasingInFileNames + realpathNative 统一)。
相关推荐
武子康1 小时前
262K 跑过,128K 却被脚本拦下:A6000 跑分里的三种“失败”不能混为一谈
人工智能·llm·agent
互联网志1 小时前
机器人物理AI操作系统问世 多形态设备协同训练重构智能作业模式
人工智能·重构·机器人
Zguigo1 小时前
【DL】神经网络学习目标|prediction|Loss|Gradient
人工智能·神经网络·学习
长江后浪博客1 小时前
RIP 颜色计算原理:RGB→CMYK 转换与陶瓷喷墨色彩管理
人工智能·rip·颜色管理·陶瓷喷墨
修远客1 小时前
质检系统:Agent的自我审查 — 不自检的Agent就像没有编辑的报社
llm·agent
2601_966949651 小时前
Python 量化数据质量校验:如何确保股票历史日线数据不存在缺失交易日?
开发语言·人工智能·爬虫·python·量化策略·量化·quantdash
阳火锅1 小时前
领导夸我日报越写越详细了,其实我只敲了 npm run commit
前端·javascript·人工智能
Aloudata1 小时前
企业级 AI 问数安全指南:如何兼顾可用性、权限与审计?
大数据·人工智能·数据分析·agent·语义编织
广东帝工智能安防1 小时前
BCAS桥梁防撞预警系统五层架构深度解析:从感知层到对接层的技术实现与选型指南
开发语言·人工智能·架构·边缘计算·桥梁防撞预警系统