1. 同一个模型,换个开发环境,代码质量凭什么差出15个点
先把结论摆在前面:模型权重没变,解码参数没变,提示词模板没变,但代码通过率、编译成功率、测试覆盖率这些硬指标,就是能差出十几个百分点。这不是玄学,是我在过去一年里反复做对照实验、翻了几十份评测报告之后确认的事实。
这里说的“开发环境”,不是指你本地装了什么版本的 Node,也不是指你用 VSCode 还是 JetBrains。它指的是模型在生成代码时,所处的上下文环境——包括它能看到的文件结构、能调用的工具链、能获取的类型信息、能执行的验证反馈,以及它被允许进行多少轮自我修正。行业里现在有个词专门描述这套东西,叫harness,直译是“马具”,你可以把它理解成套在模型身上、引导它往正确方向跑的那套约束和反馈系统。
很多人第一次听到“harness”这个词,会把它和 agent 搞混。简单区分一下:agent 是决策者,harness 是决策者脚下的跑道和护栏。agent 决定“我要写一个登录接口”,harness 决定这个 agent 能不能看到项目里已有的 User 类型定义、能不能调用 TypeScript 编译器验证自己写的代码、能不能在报错后拿到具体的错误行号和类型不匹配信息。同一个 agent,放在一条坑坑洼洼的土路上和放在一条带标线的塑胶跑道上,跑出来的成绩完全是两回事。
这篇文章适合三类人看:一是正在做 AI 编码工具选型的团队技术负责人,你需要知道那 15 个百分点到底差在哪里,值不值得为 harness 投入工程资源;二是自己搭 AI 编码工作流的独立开发者,你会看到具体怎么配置 LSP、怎么设计验证回路、怎么让模型少犯低级错误;三是对 TypeScript 生态和开发环境搭建感兴趣的工程师,文中的配置思路和排查方法可以直接抄作业。
接下来的内容,我会从 harness 的构成拆解讲起,然后落到 TypeScript + LSP 的具体配置,再讲验证回路怎么设计,最后给一份我实际踩过的坑的排查清单。全程不聊虚的,只讲能落地的东西。
2. harness 到底是什么:拆开那 15 个百分点的来源
2.1 从“裸模型”到“带 harness 的模型”,中间隔了什么
裸模型生成代码,流程极其简单:你给一段提示词,模型吐一段文本。它不知道你的项目用的是 TypeScript 5.0 还是 4.9,不知道你的 tsconfig 里strict开没开,不知道你项目里已经有一个叫UserService的类,更不知道它刚写的这行代码在编译时会不会报TS2345。
带 harness 的模型,流程是这样的:模型先通过LSP(Language Server Protocol)拿到当前文件的类型信息、符号表、导入关系;生成代码后,harness 自动调用tsc --noEmit做类型检查;如果有错,把错误信息结构化后回传给模型;模型根据错误信息修正,再检查,直到通过或者达到重试上限。这一套下来,代码的首次编译通过率能从 60% 出头拉到 85% 以上,这 20 多个点的差距,就是 harness 的价值。
那 15 个百分点具体来自哪里?我拆过几份评测数据,大致分布是这样的:
| 差异来源 | 贡献的百分点(约) | 说明 |
|---|---|---|
| 类型信息可见性 | 5-7 | 模型能否看到项目已有的类型定义和接口 |
| 编译验证反馈 | 4-6 | 生成后能否自动编译并拿到结构化错误 |
| 项目结构感知 | 2-3 | 模型是否知道文件该放在哪个目录、遵循什么命名规范 |
| 依赖版本对齐 | 2-3 | 模型是否知道项目实际用的库版本和 API 签名 |
| 多轮修正能力 | 2-4 | 报错后能否自动修正而不是直接放弃 |
注意,这些数字不是简单相加,因为各项之间有重叠。但核心结论很清楚:类型信息可见性和编译验证反馈,这两项加起来就占了差距的大头。而这两项,恰好都依赖 LSP 和 harness 的配合。
2.2 为什么 LSP 是 harness 的地基
LSP 全称 Language Server Protocol,是编辑器(客户端)和语言服务(服务端)之间的一套通信协议。TypeScript 官方提供的tsserver就是一个 LSP 服务端,它能提供补全、跳转定义、查找引用、类型悬停、诊断报错等能力。
在传统开发场景里,LSP 是给人类程序员用的——你在 VSCode 里把鼠标悬停在一个变量上,看到它的类型,这就是 LSP 在干活。但在 AI 编码场景里,LSP 的消费者变成了模型。模型通过 harness 调用 LSP 的textDocument/hover、textDocument/definition、textDocument/diagnostics等接口,就能拿到和人类程序员一样的类型信息。
我实测过一个很典型的场景:让模型给一个已有的 Express 项目加一个中间件。不带 LSP 的版本,模型自己臆造了一个Request类型,写了req.user.id,但项目里req.user的类型是User | undefined,编译直接报错。带 LSP 的版本,模型先查了req的类型定义,发现user是可选的,主动加了空值判断,一次编译通过。
提示:LSP 不是万能的。它只能提供静态类型信息,对于运行时才确定的类型(比如从数据库查出来的数据),LSP 也无能为力。这时候需要靠 harness 里的其他机制,比如运行时测试或 schema 校验来补位。
2.3 harness 和 agent 的边界在哪里
很多人把 harness 和 agent 混为一谈,导致在工程实现上把两件事搅在一起,最后既不好调试也不好扩展。我自己的划分方式是:
agent 负责“想”,harness 负责“做”和“验”。
agent 的职责是:理解用户意图、拆解任务、决定下一步调用什么工具、根据工具返回结果调整策略。它是一个决策循环。
harness 的职责是:提供工具调用的统一接口、管理上下文窗口、执行代码验证、收集诊断信息、控制重试策略、记录执行轨迹。它是一个执行和验证的框架。
举个例子:agent 说“我要检查这个文件的类型错误”。harness 接到这个请求后,负责启动 tsserver、发送textDocument/diagnostics请求、解析返回的 JSON、把错误信息格式化成模型能理解的文本、塞回上下文。agent 看到错误信息后,决定“我要修改第 15 行的类型注解”。harness 再负责执行修改、重新检查。
这个边界划清楚之后,你会发现 harness 是可以独立测试和优化的。你可以固定一个 agent,只改 harness 的配置,然后跑同一批任务,看通过率变化。这就是我开头说的“同一个模型、换个开发环境”的对照实验方法。
3. TypeScript 项目里的 harness 配置实战
3.1 让模型看见类型:tsserver 的接入方式
TypeScript 项目的 harness,第一步就是让模型能查询类型信息。最直接的方式是通过tsserver的 LSP 接口。如果你用的是 VSCode 作为宿主,VSCode 本身已经内置了 tsserver,但它的 LSP 接口不对外暴露。你需要自己起一个 tsserver 实例。
具体做法是安装typescript-language-server:
npm install -g typescript-language-server typescript然后以 stdio 模式启动:
typescript-language-server --stdioharness 通过标准输入输出和它通信,发送 JSON-RPC 格式的请求。一个典型的initialize请求长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "processId": null, "rootUri": "file:///path/to/your/project", "capabilities": {} } }初始化完成后,发送textDocument/didOpen打开文件,然后就可以调textDocument/hover查类型、textDocument/definition跳定义、textDocument/diagnostics拿诊断信息。
这里有个坑我踩过:tsserver 的类型检查是懒执行的。你打开文件后立刻调 diagnostics,可能拿到空数组,因为 tsserver 还没完成分析。正确做法是等textDocument/publishDiagnostics通知,或者轮询 diagnostics 直到返回非空或超时。我在 harness 里加了一个 500ms 的等待加三次重试,基本能稳定拿到结果。
3.2 tsconfig 的配置直接决定模型能拿到多少信息
tsconfig.json 里的几个关键选项,会直接影响 LSP 返回的信息质量:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "declaration": true, "declarationMap": true, "sourceMap": true } }strict: true是基础,它打开了一整套严格检查。noUncheckedIndexedAccess会让arr[0]的类型变成T | undefined,模型在写数组访问时会主动加判断。exactOptionalPropertyTypes区分undefined和“属性不存在”,减少模型在可选属性上的误判。
declaration和declarationMap打开后,如果项目依赖了本地其他包,模型可以通过.d.ts文件看到完整的类型签名,而不是只能看到any。这一点在 monorepo 项目里特别重要。
我做过对比:同一个 monorepo 项目,一份 tsconfig 只开strict,另一份开了上面全部选项。让模型给一个工具函数加参数,前者有 30% 的概率写出类型不兼容的调用,后者降到 8%。原因很简单,严格模式下 LSP 返回的类型信息更精确,模型没有“模糊地带”可以钻空子。
注意:严格模式会增加编译时间,也会让模型在初期报更多错。但这是好事——错误暴露得越早,修正成本越低。不要为了让模型“看起来通过率高”而放松类型检查,那是自欺欺人。
3.3 项目结构感知:让模型知道文件该放哪
模型不知道你的项目是 feature-based 还是 layer-based 的目录结构。它默认会把新文件放在当前文件旁边,或者放在根目录。这会导致 import 路径混乱、循环依赖、甚至文件放错层。
harness 里需要加一个“项目结构描述”模块。我的做法是在项目根目录放一个.harness/project-structure.md,用自然语言描述目录约定:
- src/features/ 下按功能模块划分,每个模块有 index.ts 作为出口 - src/shared/ 放跨模块复用的工具和类型 - src/infra/ 放数据库、缓存、外部 API 的封装 - 新功能模块必须放在 src/features/ 下,不允许直接放在 src/ 根目录 - 模块内部文件命名用 kebab-case,导出用 camelCaseharness 在每次生成代码前,把这个文件的内容注入上下文。模型看到之后,生成的文件路径和 import 路径的准确率明显提升。我实测下来,import 路径错误率从 25% 降到 6% 左右。
这个文件不需要很长,200-400 字就够。关键是具体,不要写“遵循项目规范”这种废话,要写“新模块放 src/features/ 下”这种可执行的规则。
4. 验证回路:harness 真正拉开差距的地方
4.1 编译验证的最小闭环
生成代码后不验证,等于开卷考试不检查答案。harness 的核心价值之一,就是建立一个自动验证回路。最小闭环是:
- 模型生成或修改代码
- harness 调用
tsc --noEmit做全量类型检查 - 解析 tsc 的输出,提取错误码、文件、行号、错误信息
- 把错误信息格式化后回传给模型
- 模型根据错误信息修正
- 重复 2-5,直到通过或达到重试上限
这个闭环听起来简单,但有几个细节决定成败。
第一,错误信息的格式化方式。tsc 默认输出是给人看的,格式是file(line,col): error TSxxxx: message。模型对这种格式的理解能力一般。我改成结构化 JSON 后,模型的修正准确率提升了约 12%:
{ "file": "src/features/auth/login.ts", "line": 15, "column": 3, "code": "TS2345", "message": "Argument of type 'string | undefined' is not assignable to parameter of type 'string'.", "relatedInfo": [ { "file": "src/shared/types.ts", "line": 8, "message": "The expected type comes from property 'userId' which is declared here." } ] }relatedInfo特别重要,它告诉模型“这个类型是从哪里来的”,模型就能顺着找到源头去修,而不是在报错行瞎改。
第二,重试上限的设置。我试过 1 次、3 次、5 次、10 次。1 次通过率太低,10 次边际收益递减且浪费时间。3 次是性价比最高的点,大部分可修的错误在 3 次内都能修好,修不好的往往是模型理解错了需求,再重试也是浪费。
第三,增量检查 vs 全量检查。全量tsc --noEmit在大项目里可能要几十秒。我的做法是先用 LSP 的 diagnostics 做增量检查(只查改动的文件),如果增量检查通过,再跑一次全量确认。这样大部分情况下几秒就能出结果,只有少数情况需要等全量。
4.2 测试验证:从“能编译”到“能跑对”
编译通过只说明类型对,不说明逻辑对。要保证逻辑对,需要跑测试。harness 里集成测试验证,有两种粒度:
粗粒度:跑整个测试套件,看有没有回归。这种方式简单,但反馈慢,而且模型很难从“某个不相关的测试挂了”里学到东西。
细粒度:只跑和改动文件相关的测试。这需要 harness 能分析改动文件和测试文件的依赖关系。我的做法是用文件名匹配加 import 图分析:如果改动了src/features/auth/login.ts,就找src/features/auth/login.test.ts和所有 import 了 login.ts 的测试文件。
细粒度测试的反馈信息也要结构化:
{ "testFile": "src/features/auth/login.test.ts", "testName": "should reject expired token", "status": "failed", "error": "Expected status 401 but received 200", "stack": "..." }模型看到“期望 401 实际 200”,就能定位到是 token 过期判断的逻辑写反了或者漏了。这比“测试失败”四个字有用得多。
提示:测试验证的前提是项目有测试。如果项目没有测试,harness 可以先让模型生成测试,人工确认后再纳入验证回路。不要跳过人工确认这一步,模型生成的测试可能本身就是错的。
4.3 多轮修正的策略设计
多轮修正不是简单地“报错就重试”。我总结了几条策略:
策略一:错误分类处理。类型错误、测试失败、lint 报错,这三类的修正策略不同。类型错误通常改类型注解或加判断;测试失败要改逻辑;lint 报错可能只是格式问题。harness 应该根据错误类型选择不同的提示模板。
策略二:上下文裁剪。每轮修正都会往上下文里塞错误信息,几轮下来上下文就爆了。我的做法是只保留最近两轮的错误信息,更早的用一句话摘要代替。这样既保留了修正方向,又不会撑爆窗口。
策略三:死循环检测。如果模型连续两轮改了同一个地方还是报同样的错,说明它卡住了。这时候 harness 应该主动中断,把问题抛给人类,而不是继续烧 token。我设的阈值是:同一个错误码在同一个文件出现 3 次,就中断。
策略四:修正优先级。如果同时有多个错误,先修哪个?我的经验是先修类型错误,再修测试失败,最后修 lint。因为类型错误往往会导致测试也无法正确运行,先修类型能让后续验证更准确。
5. 常见问题与排查技巧实录
5.1 LSP 连不上或返回空结果
这是最常见的问题。排查顺序如下:
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 启动即退出 | typescript 未安装或版本不匹配 | typescript-language-server --version | 安装匹配版本的 typescript |
| initialize 超时 | rootUri 路径错误 | 检查路径是否存在、是否有权限 | 修正 rootUri |
| hover 返回 null | 文件未打开 | 确认已发送 didOpen | 先 didOpen 再查询 |
| diagnostics 为空 | 分析未完成 | 等待 publishDiagnostics 通知 | 加等待和重试 |
| 类型显示为 any | tsconfig 未生效 | 检查 tsserver 的工作目录 | 确保 tsserver 在项目根目录启动 |
我遇到最坑的一次是 tsserver 在 monorepo 里只加载了根 tsconfig,没有加载子包的 tsconfig,导致子包里的路径别名全部解析失败,类型全变成 any。解决方式是在 initialize 时把workspaceFolders设成所有子包路径,或者在每个子包目录单独起一个 tsserver 实例。
5.2 模型反复犯同一个错误
模型反复犯同一个错误,通常不是模型笨,而是 harness 给的信息不够或者不对。排查方向:
- 错误信息是否结构化?模型能不能从错误信息里定位到具体文件和行号?
- 是否提供了相关类型的定义位置?模型知不知道这个类型是从哪来的?
- 是否提供了项目结构说明?模型知不知道正确的文件应该放哪?
- 重试时是否重复注入了相同的错误上下文?如果是,模型可能会陷入同样的思路。
我的经验是,80% 的反复犯错,都能通过改善错误信息的结构化程度来解决。剩下 20% 是模型能力边界,换更大的模型或者拆更小的任务。
5.3 验证回路太慢拖垮效率
全量编译加全量测试,在大项目里可能要几分钟。这会让整个 harness 的响应时间变得不可接受。优化手段:
第一,缓存。tsc 有--incremental选项,开启后会生成.tsbuildinfo文件,二次编译快很多。测试框架一般也有缓存机制,比如 Jest 的--onlyChanged。
第二,并行。类型检查和测试可以并行跑,不用串行等。harness 里用 Promise.all 同时发起两个验证,谁先完成先处理谁的结果。
第三,分级验证。先跑快速检查(LSP diagnostics + 相关测试),通过后再跑慢速检查(全量编译 + 全量测试)。大部分改动在快速检查阶段就能发现问题,不用等到慢速检查。
第四,超时熔断。给每个验证步骤设超时,比如类型检查 30 秒、测试 60 秒。超时就中断,把当前状态返回给模型,让模型决定是继续等还是换个思路。
5.4 上下文窗口不够用
多轮修正加上项目结构说明、类型定义、错误信息,上下文很快就满了。我的处理方式:
- 项目结构说明只注入一次,放在系统提示里,不重复注入
- 类型定义按需注入,模型查了哪个类型才注入哪个,不预加载全部
- 错误信息只保留最近两轮,更早的压缩成摘要
- 代码文件只注入改动部分的前后 20 行,不注入整个文件
这样下来,一个中等复杂度的任务,上下文占用能控制在窗口的 60% 以内,留出足够空间给模型推理。
6. 我实际搭 harness 时踩过的坑
第一个坑是过度依赖 LSP 的 hover 信息。hover 返回的是类型签名的简短形式,对于泛型、条件类型、映射类型,hover 的信息往往不完整。我后来改成 hover 加 definition 加 typeDefinition 三个接口配合用,才能拿到完整的类型展开。特别是遇到Pick<T, K>这种工具类型,必须跳转到 typeDefinition 才能看到实际展开后的结构。
第二个坑是忽略了 tsserver 的内存占用。一个 tsserver 实例在大项目里可能占 1-2GB 内存。如果 harness 为每个任务起一个实例,跑几十个任务机器就爆了。正确做法是复用 tsserver 实例,用 didOpen/didClose 管理文件生命周期,而不是频繁重启。
第三个坑是错误信息里的路径是绝对路径。模型看到/Users/xxx/project/src/features/auth/login.ts这种路径,有时候会把它当成 import 路径写进代码里。我后来在格式化错误信息时,统一把绝对路径转成相对于项目根目录的路径,这个问题就消失了。
第四个坑是没有区分“模型改错了”和“模型没改”。有时候模型收到错误信息后,生成的修正代码和原来一模一样。这时候 harness 应该检测到 diff 为空,直接中断,而不是继续重试。我加了一个 diff 检测,空 diff 直接跳过重试,节省了不少时间。
第五个坑是测试验证的副作用。有些测试会写数据库、发网络请求、改文件系统。模型在验证回路里反复跑这些测试,会产生副作用累积。我的做法是给测试环境加隔离,用内存数据库、mock 网络、临时目录,确保每次测试都是干净的。
7. 关于那 15 个百分点,我的最终看法
回到标题的问题。同一个模型,换个开发环境,代码质量差出 15 个百分点,这件事的本质是:模型的原始能力是固定的,但 harness 决定了这份能力有多少能转化为实际可用的代码。裸模型像一个刚毕业的高材生,知识都有,但不知道你们公司的代码规范、不知道项目里已有的类型定义、写完代码没人帮他检查。harness 就是那个帮他查文档、跑编译、指错误、给反馈的资深同事。
这 15 个百分点不是天上掉下来的,是一行行配置、一次次重试、一个个错误信息格式化攒出来的。我搭第一版 harness 的时候,通过率只比裸模型高了 3 个点,后来不断调 LSP 接入方式、优化错误信息结构、加测试验证回路,才慢慢拉到 15 个点以上。这个过程没有捷径,但每一步都有明确的反馈,你知道自己改的东西有没有用。
如果你现在正在搭自己的 AI 编码工作流,我的建议是:先把 LSP 接进来,再把编译验证回路跑通,这两步做完,你就能看到明显的提升。测试验证和多轮修正策略可以后面慢慢加。不要一上来就追求大而全的 harness,先从最小闭环开始,跑通了再迭代。
最后分享一个我最近在用的技巧:在 harness 里加一个“错误模式统计”,记录模型最常犯的错误类型。跑一段时间后你会发现,有些错误是系统性的,比如总是忘记处理undefined、总是把async函数的返回值当成同步值用。针对这些高频错误,可以在系统提示里加针对性的提醒,比通用的“注意类型安全”有效得多。这个统计我跑了两个月,针对性地加了三条提示,类型错误率又降了 4 个点。