跳到主要内容

从模型输出到文件 diff:构建与应用流水线

30 秒导读: 规划阶段(见 01-tell-loop)里,模型已经吐出了「这个文件要怎么改」的一段带占位符的代码片段。本章讲的是下一步:这段话怎么被可靠地落成真实文件的精确 diff。核心是一条四级容错流水线——先用 tree-sitter 直接把片段拼进原文件,拼不干净就多路竞速找补,还不行就让模型整文件重写,最后统一转成 git diff 存库。


1. 这是什么(零基础也能懂)

1.1 先分清「规划」和「构建」两件事

Plandex 把「改代码」拆成了两个独立阶段,别混:

阶段干什么产物本章
Plan / Tell(规划)模型读上下文,决定改哪些文件、每个文件改成什么样一段带占位符的代码片段 + 一句描述(desc)不讲(见 01)
Build(构建)把那段片段精确落到真实文件上,算出 diff一组 Replacement(旧文本→新文本)就是本章

为什么要分开?因为让模型「一字不差地重写整个大文件」既慢又贵还容易漏行。Plandex 让模型只写改动的那几段,中间用占位符 // ... existing code ... 代表「这里保持原样」,然后由服务端机械地把占位符展开、把改动拼回去。这个「拼回去」的过程,就是 build。

1.2 模型到底给了什么

想象你让 AI 改一个 200 行的文件,它不会重抄 200 行,而是给你这样一段(proposedContent):

// ... existing code ...

func Add(a, b int) int {
return a + b + 1 // 改了这里
}

// ... existing code ...

外加一句描述(desc),形如 Type: replace / Replace: lines 10-14

服务端的任务:把这段东西和原文件对齐,搞清楚那两个 ... existing code ... 各自代表原文件的哪些行,Add 函数替换掉原来的哪一段,最后产出一个真实、完整、语法正确的新文件。

1.3 难在哪(为什么不是简单的字符串替换)

模型是不可靠的。它给的片段常常有这些毛病:

  • 占位符位置模糊——一个 ... existing code ... 到底吃掉原文件多少行?
  • 缩进/空白和原文件对不齐;
  • 想「替换」却不小心删掉了不该删的代码;
  • 把某段代码重复贴了两次;
  • 落点有歧义——同样一行 } 在文件里出现 20 次,拼哪一个?

所以 build 不是 strings.Replace 那么简单,而是一套带校验、带兜底的流水线:每一级都假设「上一级可能拼错了」,并准备好下一级去补救。

1.4 一句话直觉

把它想成装修工照着草图砌墙:模型给的是草图(哪几块墙要改),... existing code ... 是草图上「此处保持原样」的省略号。装修工(build 流水线)先按草图快速砌(tree-sitter 直接应用);砌歪了就叫来三个师傅同时返工谁快用谁(竞速);全砌坏了就干脆整面墙推倒重砌(整文件兜底);最后拍照对比出「改了哪几块」(git diff)交工。


2. 顶层全景(它大概怎么转)

2.1 四级容错流水线

整条流水线的骨架是**「乐观快路 + 逐级兜底」**:能一步到位就一步到位,出问题才逐级加码。

模型给的 proposedContent + desc

┌───────────────▼───────────────┐
①直接应用 │ syntax.ApplyChanges │ 锚点匹配把片段拼进原文件
(最快) │ (generic / tree-sitter) │ → NewFile + NeedsVerifyReasons
└───────────────┬───────────────┘

validateSyntax(NewFile) tree-sitter 校验语法

┌────────────▼────────────┐
│ 没有 verify 理由 & │ 是 → 直接信任,跳到 ④
│ 没有语法错误? │
└────────────┬────────────┘
│ 否(拼得不干净)
┌───────────────▼───────────────┐
②竞速 │ buildRace:三条路同时跑 │
│ A. 校验-修复循环(LLM) │ 谁先给出
│ B. fast-apply hook + 校验 │ 「有效结果」
│ C. 整文件重写兜底(LLM) │ 谁就赢
└───────────────┬───────────────┘
│ 胜出者的 content
┌───────────────▼───────────────┐
④落库 │ GetDiffReplacements │ git diff 原文件 vs 新文件
│ → []Replacement → 存库+提交 │ → 拆成 Replacement 存库
└────────────────────────────────┘

怎么读这张图: 从上往下是「越来越贵的容错」。① 是纯本地字符串/语法操作,零 LLM 调用,绝大多数简单编辑走完 ① 就结束了;只有 ① 拼不干净时才进入 ② 的多路竞速——那里会真正再调模型。③(整文件重写)是 ② 内部的第三条路,不是独立一级。

2.2 部件与文件对照

部件干什么文件 / 符号
构建总调度从 pending build 出队、逐文件构建、维护构建队列model/plan/build_exec.go:Build / execPlanBuild
前置加载拉原文件状态、选 tree-sitter parser、校验原文件语法model/plan/build_load.go:loadBuildFile
结构化编辑总流程串起「直接应用→竞速→落库」model/plan/build_structured_edits.go:buildStructuredEdits
直接应用(锚点匹配)把片段拼进原文件,输出 verify 信号syntax/structured_edits_apply.go:ApplyChanges
通用锚点应用纯字符串锚点匹配(不依赖语法树)syntax/structured_edits_generic.go:ExecApplyGeneric
tree-sitter 应用用语法树消解歧义落点syntax/structured_edits_tree_sitter.go:ExecApplyTreeSitter
语法校验tree-sitter 解析新文件,报语法错、超时降级syntax/validate.go:ValidateWithParsers
多路竞速校验循环 / fast-apply / 整文件三路抢跑model/plan/build_race.go:buildRace
校验-修复循环让 LLM 逐段验证并给出替换model/plan/build_validate_and_fix.go:buildValidateLoop
整文件兜底让 LLM 整文件重写model/plan/build_whole_file.go:buildWholeFileFallback
diff 落库新旧文件转成 Replacementdiff/diff.go:GetDiffReplacements
结束/提交存 PlanFileResult、git 提交model/plan/build_finish.go:onFinishBuildFile / onFinishBuild

2.3 主线走一遍(高层)

  1. Build 从数据库拉出所有待构建文件,每个路径起一个 goroutine 排队(build_exec.go:Build)。
  2. 每个文件:loadBuildFile 拉原文件内容、选好 parser、校验原文件语法是否本来就坏(build_load.go:loadBuildFile)。
  3. resolvePreBuildState 定出「原文件状态」preBuildState——可能来自当前 plan、来自上下文,或者是新文件(build_exec.go:resolvePreBuildState)。
  4. 进入 buildStructuredEdits:先 ApplyChanges 直接拼,再 validateSyntax 校验;干净就用,不干净就进 buildRace(build_structured_edits.go:buildStructuredEdits)。
  5. 拿到最终文件内容后,GetDiffReplacements 算 diff,存 PlanFileResult,全部文件构建完就 git 提交(build_finish.go)。

3. 核心原理(逐个机制,由浅入深)

3.1 直接应用:锚点匹配把片段拼回原文件

它要解决的小问题

模型给的片段里,... existing code ... 是省略号,真实文件里没有这一行。要把片段「还原」成完整文件,就得搞清楚每个省略号代表原文件的哪一段——本质是把「片段的行」和「原文件的行」对齐,对齐用的参照点叫锚点(anchor)

思路:唯一匹配的行就是可靠锚点

直觉很朴素:如果片段里某一行在原文件里唯一地出现一次,那它俩一定是同一行。以这些唯一行为锚点钉住位置,夹在锚点之间的 ... existing code ... 就能推断出对应的原文件行区间。

ApplyChanges 先扫一遍片段,把每一行分成三类(structured_edits_apply.go:113-134,函数 isRef / isRemoval):

行的类型判定含义
引用行(Reference)isRef:含 ... existing code ...rest ofstart ofend of占位符,代表「原文件的一段保持不动」
移除行(Removal)isRemoval:含 plandex: removed显式标记「这里删掉」
普通行其余模型真正写的新代码

原理演示

# 示意,非源码:锚点匹配的核心直觉
proposed = ["... existing code ...", "return a+b+1", "... existing code ..."]
original = ["package m", "func Add(a,b int) int {", "return a+b", "}"]

# "return a+b+1" 在原文件里没有 → 是新代码
# 两个省略号夹着它 → 上省略号=original[0:1], 下省略号=original[3:4]
# 于是拼出:package m / func Add... / return a+b+1(替换) / }

真实实现:两套应用引擎

ApplyChanges 先跑通用引擎 ExecApplyGeneric(structured_edits_apply.go:303),它完全靠字符串锚点、不碰语法树。核心是 buildAnchorMap(structured_edits_generic.go:464),做两件事:

  • 唯一匹配:片段某行内容在某个区间里原文件只出现一次、片段也只出现一次,直接钉成锚点(structured_edits_generic.go:642-655)。
  • 重复行 outside-in 匹配:内容重复的行(比如一堆 }),从区间两端向内配对,减少错配(structured_edits_generic.go:718-796)。

AddMissingStartEndRefs 这个开关会在片段首尾自动补一个 ... existing code ...——因为模型经常忘了写首尾的省略号,不补的话文件头尾就会被误删(structured_edits_apply.go:228-277)。

关键细节

buildAnchorMap递归分治的:先在全文件找唯一锚点,再把文件按锚点切成子区间,每个子区间内递归匹配(matchSection,structured_edits_generic.go:612-807)。这样局部重复的行在小区间里往往就变唯一了。


3.2 三种 NeedsVerifyReason:拼完之后的「不放心」信号

它要解决的小问题

锚点匹配是启发式的,可能拼错。ApplyChanges 拼完不会盲目自信,而是自查:有没有可疑迹象? 有的话就往结果里塞一个 NeedsVerifyReason,告诉上层「这个结果不能直接信,得校验」。

三种信号

信号定义在 structured_edits_apply.go:26-30:

信号常量什么时候触发潜台词
代码被删code_removed新文件比原文件短;或 desc 是 replace/remove/overwrite;或原文件有某行在新文件里不见了「可能删多了」
代码重复code_duplicated(当前版本已注释停用,见下)「可能贴重了」
落点歧义ambiguous_location锚点算出的行区间非法(start>end),或一个引用块对应多段原文「不知道拼哪」

code_removed 的三条判据

这是最常触发的信号,ApplyChanges 用三种方式抓「删多了」(structured_edits_apply.go:358-401):

if len(NewFile) < len(original): → code_removed # ① 长度变短了
else if isRemove||isReplace||overwrite: → code_removed # ② 描述本就是删/换
else: # ③ 逐行比对
for line in originalLines:
if line not in newLines: → code_removed; break

① 和 ② 是「宁可错杀」——只要是替换/删除类操作,或文件变短,一律标记待验证。③ 更精细:原文件有任何一行在新文件里彻底消失,就疑似误删。

ambiguous_location 的触发

在通用引擎里,当一个引用块(单个 ... existing code ...)最后被发现对应了多段不连续的原文(numRefs > 1),就没法确定性拼接,直接标 ambiguous_location(structured_edits_generic.go:245-253)。另一处是算出的切片下标非法 start > end(structured_edits_generic.go:194-212)——这也说明锚点乱了。

注: code_duplicated 的检测代码在当前 commit 里被整块注释掉了(structured_edits_apply.go:403-457),注释说明「因为现在所有替换都会走校验,重复检测已冗余」。常量仍保留,但运行时不再产生该信号。这是一个诚实的现状——不要以为它还在跑。


3.3 tree-sitter 升级:用语法树消解歧义落点

它要解决的小问题

通用引擎只会字符串匹配,遇到 ambiguous_location 就投降。但很多歧义其实靠语法结构能解开——比如「这个 ... existing code ... 在某个函数体内部」,那它的边界就是那个函数节点的边界。

思路:只在「唯一一个 ambiguous_location」时才升级

ApplyChanges 有个很克制的升级条件(structured_edits_apply.go:332-354):

if 只有一个 NeedsVerifyReason 且它是 ambiguous_location 且 parser != nil:
res, err = ExecApplyTreeSitter(...) # 用语法树重试
if err: 回退到通用引擎的结果
if 仍有 verify 理由: 直接返回(交给上层竞速)

也就是说:tree-sitter 只是针对「纯落点歧义」的一次补救,不是主力。如果连它都拼不干净,就不再纠缠,把结果连同信号交给 buildRace 处理。

真实实现

ExecApplyTreeSitter(structured_edits_tree_sitter.go:29)把原文件和片段都用 tree-sitter 解析成语法树,建立「行号→节点」索引(BuildNodeIndex),然后逐行走片段:

  • 遇到普通行,用 findNextAnchor 在原文件里找匹配行,匹配上就同时拿到该行所在节点的结束行(EndPoint().Row),从而知道一个多行结构从哪到哪(structured_edits_tree_sitter.go:119-189)。
  • 遇到引用行(已被规范化成 // ref 注释,structured_edits_tree_sitter.go:77-88),记下起点,等下一个匹配锚点出现时,把中间那段原文当作省略号的内容回填(writeRefs,structured_edits_tree_sitter.go:270-383)。
  • 一个引用块对应多段时,调 getSections 按父节点的子结构切分回填。

它同样会在切片下标非法时补 ambiguous_location 并放弃(structured_edits_tree_sitter.go:317)。

关键细节:parser 从哪来、什么时候没有

parser 在前置阶段选好:loadBuildFilesyntax.GetParserForPath 按扩展名选语言(build_load.go:173)。如果文件类型不支持、或原文件本来语法就坏(preBuildStateSyntaxInvalid)、或语法检查超时(syntaxCheckTimedOut),后续就不再用 tree-sitter 校验(见 3.4)。注意 ApplyChanges 本身对没有 parser 是容忍的——build_exec.go 的注释明说「结构化编辑策略现在无论是否支持 tree-sitter 都能工作」(build_exec.go:395),没有语法树时就退回纯字符串的通用引擎。


3.4 语法校验:tree-sitter 解析 + 超时降级

它要解决的小问题

拼出来的新文件,语法是不是合法的?一个括号没配对、一个函数体缺了 },都说明拼错了。这是判断「① 直接应用能不能信」的另一半依据(另一半是有没有 verify 信号)。

思路:解析成语法树,看根节点有没有 error

tree-sitter 解析后,如果语法树根节点 HasError(),就说明有语法错误。ValidateWithParsers(validate.go:35)把出错的具体行号提取成人类可读的错误串(insertErrorMarkers,validate.go:93,如 Invalid syntax on line 42),这些串后面会喂给修复用的 LLM。

两个工程化细节(精华)

① 超时降级,不阻塞构建。 解析设了 500ms 硬超时(parserTimeout,validate.go:15);tree-sitter 报 operation limit was hit 时,不当成错误,而是返回 TimedOut: true(validate.go:48-49)。上层 validateSyntax 看到超时就记住这个文件放弃语法校验(syntaxCheckTimedOut = true),后续都跳过——宁可不校验,也不能让一个病态文件卡死整个 build(build_structured_edits.go:223-238)。

② 备用 parser。 主 parser 报错时,如果有 fallback parser(比如 JSX 之于 JS),再用它解析一次,通过就用 fallback 的语言(validate.go:60-77)。

「直接应用是否有效」的判定

回到总流程,buildStructuredEdits 把两半合起来(build_structured_edits.go:124-127):

autoApplyHasSyntaxErrors := len(autoApplySyntaxErrors) > 0
hasNeedsVerifyReasons := len(NeedsVerifyReasons) > 0
autoApplyIsValid = !autoApplyHasSyntaxErrors && !hasNeedsVerifyReasons

两个条件全过才算「直接应用有效」,才敢跳过 LLM 直接用结果(AutoApplySuccess = true,build_structured_edits.go:139-141)。否则进 buildRace


3.5 多路竞速 buildRace:三条路谁先产出有效结果谁赢

这是整个 build 里工程含量最高的一块,值得单独细讲。

它要解决的小问题

直接应用拼不干净了(有语法错或 verify 信号)。现在需要一个可靠但可能慢的方式把文件修对。问题是:哪种修法最快最准,事前不知道。答案:都试,谁先给出有效结果就用谁,其余取消。

三条路

buildRace(build_race.go:34)最多同时跑三条路,共用一个可取消的 buildCtx:

做什么何时启动符号
A. 校验-修复循环让 LLM 看 diff,逐段验证并给出 <Old>/<New> 替换,最多 3 轮总是立即启动buildValidateLoop(build_validate_and_fix.go:44)
B. fast-apply hook调外部 fast-apply 模型直接合并,再校验若已触发 fast-applymaybeStartFastApply(build_race.go:114)
C. 整文件重写兜底让 LLM 整文件重写惰性:A 失败或收到「不正确」标记时startWholeFileBuild(build_race.go:80)

竞速的通道设计

竞速靠两个 channel + 一个计数器(build_race.go:57-76, 271-300):

resCh (容量1) ── 任一路产出有效结果 → 主循环 return,defer 取消其余
errCh (容量3) ── 每条路失败发一个 err;收满 maxErrs(=3) → 全败,返回汇总错误

主循环是个 select(build_race.go:274):

for {
select {
case <-buildCtx.Done(): return 取消错误
case err := <-errCh: errChNumReceived++
if errChNumReceived >= maxErrs: 返回「所有尝试都失败: [汇总]」
if !startedFallbacks: 启动兜底(B→C)
case res := <-resCh: return res # 有人赢了,直接收工
}
}

maxErrs = 3(build_race.go:57)对应三条路;收满三个错误才判定彻底失败,把三条路的错误 errs 一起汇总返回(build_race.go:287-290)。任何一条先把 res 送进 resCh,主循环立刻返回,defer cancelBuild() 取消掉还在跑的其余路(build_race.go:40-43)。

逐级触发:PlandexIncorrect 标记与失败链

竞速不是一上来就三路全开,而是惰性升级:

  • A 路(校验循环)在首轮流式输出时挂了个钩子 onInitialStream(build_race.go:213-223)。一旦 LLM 在回答里吐出 <PlandexIncorrect/> <PlandexComments>,说明模型自己认定「这片段就是错的,光改替换救不了」,立刻调 startFallbacks 把兜底路拉起来——顺带把模型给的 comments 传给整文件重写当提示(build_race.go:217)。
  • startFallbacks 先试 B(fast-apply),B 未定义或失败,其 onFail 回调再拉起 C(整文件重写)(build_race.go:203-210)。这是一条 A→B→C 的降级链
  • 如果 A 路不是「不正确」而是直接报错,主循环在 errCh 分支里也会兜底 startFallbacks("")(build_race.go:292-295)——注意这里 comments 传空串,让整文件重写自己先分类。

一句话: 竞速 = 「乐观地让改替换的 A 路先跑,同时用 fast-apply / 整文件重写做保险」。触发保险的信号有两种:模型自曝 <PlandexIncorrect/>,或某条路抛错。


3.6 校验-修复循环 buildValidateLoop(竞速的 A 路)

它要解决的小问题

直接应用拼出来的新文件可能有错。让 LLM 当「审校员」:给它看原文件(带行号) + 拼出的 diff + 语法错 + verify 理由,让它判断对不对;不对就给出精确的 <Old>/<New> 替换来修,循环最多 3 轮。

循环结构

buildValidateLoop(build_validate_and_fix.go:44)最多 MaxValidationFixAttempts = 3 轮(:22),每轮调一次 buildValidate:

for attempt in 1..3:
res = buildValidate(...) # 调 LLM,拿回校验结论+替换
updated = res.updated
syntaxErrors = validateSyntax(updated) # 每轮修完再验一次语法
if res.valid and no syntaxErrors: return 成功
记下 problem,进入下一轮
返回 valid=false + 所有 problem 拼起来

精华细节

① 第二轮起换更强的模型。 如果第一轮没修对,且模型包配了 StrongModel,从第 3 次尝试起切到强模型(build_validate_and_fix.go:99-102)——便宜模型先试,不行才上贵的。

② 校验-only 模式。 竞速里 A 路和「构建替换」是并行的,所以 fast-apply 那条路调 buildValidateLoop 时用 validateOnlyOnFinalAttempt + maxAttempts:1,只让 LLM 判断对错、不再产替换(build_race.go:163-175),避免重复劳动。对应 buildValidate 里把 stop 词设成 <PlandexComments> / <PlandexReplacements>(build_validate_and_fix.go:255-257)。

③ 替换靠行号前缀锚定。 LLM 返回的 <Old> 必须以 pdx- 行号前缀开头(build_validate_and_fix.go:389),handleXMLResponse 据此从带行号的原文件里精确取出要替换的行区间,再做 strings.Replace(..., 1)(build_validate_and_fix.go:394-433)。用行号而非内容匹配,避免了「同样的行替换错地方」。若模型答 <PlandexCorrect/>,直接判定 updated 有效(build_validate_and_fix.go:336-343)。

④ 请求出错自带指数退避重试。 buildValidate 里网络/模型错误走 validationRetryOrError,最多 MaxBuildErrorRetries=3 次,退避时间 n²*200ms + rand 半指数增长(build_validate_and_fix.go:455-486)。


3.7 整文件重写兜底 buildWholeFileFallback(竞速的 C 路)

它要解决的小问题

当片段级的修补都救不回来(锚点乱、模型自曝不正确),最后的保险:放弃拼接,让 LLM 拿着原文件 + 片段 + 描述 + comments,直接重写出完整文件。慢、贵,但最鲁棒。

真实实现

buildWholeFileFallback(build_whole_file.go:20):

  1. 把原文件和片段都加上行号(shared.AddLineNums),连同 desc、comments 组进 GetWholeFilePrompt(build_whole_file.go:40-43)。
  2. 用专门的 WholeFileBuilder 角色模型,并按输入/输出 token 量选合适的模型档位(build_whole_file.go:28, 60-61)——见 04-models-roles
  3. 从返回里抽 <PlandexWholeFile>...</PlandexWholeFile> 标签内的完整文件(build_whole_file.go:133)。
  4. 抽不到就走 wholeFileRetryOrError,同样半指数退避重试最多 3 次(build_whole_file.go:143-171)。

精华细节:预测输出(predicted output)省钱

如果模型支持 predicted output 且有 comments,就把原文件整个塞进 prediction 字段(build_whole_file.go:67-74)。因为整文件重写的输出和原文件高度重合,把原文件当「预测」交给 OpenAI 能大幅降低生成延迟和成本——这是对「重写整文件很浪费」这一痛点的针对性优化。


4. 深入实现:从新文件到 diff 落库

4.1 GetDiffReplacements:git diff 拆成 Replacement

拿到最终新文件后,buildStructuredEdits 不直接存整个文件,而是算出最小 diff再存(build_structured_edits.go:196)。为什么?diff 才是能展示给人审、能被 CLI 沙箱逐块 apply 的粒度(见 05-diff-sandbox-cli)。

GetDiffReplacements(diff/diff.go:67)的做法很「务实」:

  1. GetDiffs 把原文件和新文件各写进临时目录,直接 git diff --no-indexgit 本体算 diff(diff/diff.go:18-58)——退出码 1(有差异)是正常,不当错误。
  2. 逐行扫 diff 输出,按 @@ hunk 头切块,- 行进 oldLines、+ 行进 newLines、 上下文行两边都加(diff/diff.go:79-121)。
  3. 每个 hunk 变成一个 change{Old, New, Line},再包成 shared.Replacement{Id, Old, New}(diff/diff.go:135-145)。

精华: 不自己写 diff 算法,直接借 git。既稳又省事,代价是每次 build 起一个 git 子进程和临时目录。

落库前还有个清理:StripAddedBlankLines 去掉拼接可能引入的首尾空行(build_structured_edits.go:193)。每个 replacement 的 Summary 统一填成 desc(build_structured_edits.go:204-206)。

4.2 调度、加载、结束:流水线的骨架

入口与队列(build_exec.go)。 Build 拉出 pendingBuildsByPath,每个路径一个 goroutine queueBuilds(build_exec.go:88-92)。同一路径的构建串行(靠 IsBuildingByPath 标志 + BuildQueuesByPath 队列,build_exec.go:queueBuild),不同路径并行。execPlanBuild 里还处理了移动/删除/重置/新文件等特殊操作,普通编辑最后才落到 buildStructuredEdits(build_exec.go:225-398)。

前置加载(build_load.go)。 loadBuildFile 选 parser 并先校验原文件本身的语法——原文件本来就坏(preBuildStateSyntaxInvalid)或校验超时(syntaxCheckTimedOut)都会记进 fileState,让后续跳过对新文件的语法校验(build_load.go:173-192)。这很关键:如果原文件本来就语法错,那新文件报语法错就没意义。

结束与提交(build_finish.go)。 每个文件构建完调 onFinishBuildFile,加写锁存 PlanFileResult(build_finish.go:165-215),再触发 DidFinishBuilderRun hook 上报本次构建的遥测。全部文件都构建完(BuildFinished()),onFinishBuild 加写锁把所有改动 GitAddAndCommit 一次性提交(build_finish.go:128)。出错走 onBuildFileError,把错误推进 StreamDoneCh(build_finish.go:276-313)。

4.3 关键数据结构

每个文件的构建状态挂在 activeBuildStreamFileState(build_state.go:31-49):

字段含义
preBuildState原文件内容(构建的基准)
parser / language选定的 tree-sitter parser 与语言
preBuildStateSyntaxInvalid原文件本来就语法错 → 跳过新文件语法校验
syntaxCheckTimedOut语法校验超时过 → 后续放弃校验
builderRun遥测:各阶段耗时、走了哪条路、成功与否

builderRun 里散落着大量埋点(AutoApplySuccess / FastApplySuccess / ReplacementSuccess / BuiltWholeFile …),精确记录这次编辑最终靠哪一级容错落地——这既是可观测性,也间接印证了「四级流水线」的设计。


5. 巧妙之处(可借鉴的技术)

  • 乐观快路 + 逐级兜底。 简单编辑走完纯本地的 ①(零 LLM 调用)就结束,只有拼不干净才升级到花钱的竞速。把「便宜且常见」的路径做短,是成本控制的关键(build_structured_edits.go:139)。
  • 多路竞速而非串行重试。 不赌某一种修法一定对,而是让「改替换 / fast-apply / 整文件重写」同时跑,谁先出有效结果用谁,其余用 context 取消(build_race.go:34, 40-43)。用并发换延迟。
  • 模型自曝错误驱动升级。 让 LLM 输出 <PlandexIncorrect/> 主动承认「片段级修补救不了」,据此在后台提前拉起整文件重写,而不是等三轮都失败(build_race.go:213-223)。
  • 行号前缀锚定替换。 让模型用 pdx- 行号定位要替换的区间,而非靠内容字符串匹配,规避了重复行错配(build_validate_and_fix.go:389-433)。
  • 语法校验硬超时降级。 500ms 超时后直接放弃该文件的语法校验并记住,绝不让病态输入卡死构建(validate.go:15, 48-49)。
  • 借 git 算 diff。 不重造 diff 轮子,临时目录 + git diff --no-index,稳定可靠(diff/diff.go:42)。
  • predicted output 省钱。 整文件重写时把原文件当预测输入喂给 OpenAI,利用输出与原文件的高重合度压低成本(build_whole_file.go:67-74)。

6. 边界与局限

  • code_duplicated 当前不生效。 检测代码整块被注释,常量保留但运行时不产生该信号(structured_edits_apply.go:403-457)。设计上是三种信号,实际只有两种活跃。
  • tree-sitter 升级条件很窄。 只在「恰好一个 ambiguous_location、且有 parser」时才升级;多信号叠加或无 parser 时,tree-sitter 不介入,直接靠竞速(structured_edits_apply.go:332)。
  • 无语法树语言只能纯字符串锚点。 不被 tree-sitter 支持的文件类型,落点歧义更难自动解开,更依赖 LLM 竞速兜底。
  • code_removed 判据偏保守。 长度变短或替换类操作一律标记待验证(structured_edits_apply.go:360-365),会把一些其实正确的删除也送进竞速,增加 LLM 调用——用误报换安全。
  • 依赖外部 git 与临时目录。 GetDiffs 每次起 git 子进程写临时文件(diff/diff.go:18-42),在无 git 环境或高频构建下是隐性成本。
  • 每文件构建串行。 同一路径的多次构建靠队列串行执行(build_exec.go:queueBuild),保证一致性但不并行同路径。

本章不覆盖: 模型输出是怎么流式产生的、片段和 desc 从哪来(见 01-tell-loop);diff 落到 CLI 沙箱后如何被人 apply(见 05-diff-sandbox-cli)。上下文与项目地图见 03-context-maps,多模型角色见 04-models-roles


7. 代码地图(导航索引)

主题文件路径符号名
结构化编辑总流程app/server/model/plan/build_structured_edits.gobuildStructuredEdits
新文件语法校验封装app/server/model/plan/build_structured_edits.govalidateSyntax
直接应用(锚点匹配入口)app/server/syntax/structured_edits_apply.goApplyChanges
verify 信号常量app/server/syntax/structured_edits_apply.goNeedsVerifyReasonCodeRemoved / NeedsVerifyReasonCodeDuplicated / NeedsVerifyReasonAmbiguousLocation
引用/移除行判定app/server/syntax/structured_edits_apply.goisRef / isRemoval
通用锚点应用引擎app/server/syntax/structured_edits_generic.goExecApplyGeneric
锚点映射(递归分治)app/server/syntax/structured_edits_generic.gobuildAnchorMap / matchSection
tree-sitter 应用引擎app/server/syntax/structured_edits_tree_sitter.goExecApplyTreeSitter
语法校验 + 超时降级app/server/syntax/validate.goValidateWithParsers / insertErrorMarkers
parser 超时常量app/server/syntax/validate.goparserTimeout
多路竞速app/server/model/plan/build_race.gobuildRace
PlandexIncorrect 触发兜底app/server/model/plan/build_race.goonInitialStream / startFallbacks
fast-apply 竞速路app/server/model/plan/build_race.gomaybeStartFastApply
校验-修复循环app/server/model/plan/build_validate_and_fix.gobuildValidateLoop / buildValidate
LLM 替换解析(行号锚定)app/server/model/plan/build_validate_and_fix.gohandleXMLResponse
整文件重写兜底app/server/model/plan/build_whole_file.gobuildWholeFileFallback / wholeFileRetryOrError
diff 落库app/server/diff/diff.goGetDiffReplacements / GetDiffs / processHunk
构建入口/队列/调度app/server/model/plan/build_exec.goBuild / execPlanBuild / buildFile / resolvePreBuildState
前置加载与原文件校验app/server/model/plan/build_load.goloadBuildFile / loadPendingBuilds
结束/存库/git 提交app/server/model/plan/build_finish.goonFinishBuildFile / onFinishBuild / onBuildFileError
每文件构建状态app/server/model/plan/build_state.goactiveBuildStreamFileState / MaxBuildErrorRetries