化:代碼行號注入與確定性目錄生成實(shí)戰(zhàn))
1. 先說清楚這次優(yōu)化到底在治什么病DeepWiki 這類自動文檔生成工具核心價值是讓 AI 去讀代碼倉庫、再把理解沉淀成一篇篇可維護(hù)的文檔。但跑過一段時間的同學(xué)應(yīng)該都有體會文檔“能生成”和文檔“能長期用”完全是兩回事。我這次做的優(yōu)化就是針對兩個最刺手的細(xì)節(jié)——代碼行號Code Line Numbers和確定性目錄生成Deterministic Table of Contents Generation。先說代碼行號。AI 在解釋某個函數(shù)時經(jīng)常會在文檔里寫“請看src/service.py的 handle_request 方法”但這句話是空的讀者得自己打開文件去翻。更麻煩的是如果文檔里的代碼塊沒有行號團(tuán)隊在評審、答疑、定位問題時只能靠“大約在第 80 行附近”這種模糊表述來回溝通成本非常高。如果能在生成的 Markdown 代碼塊里帶上真實(shí)文件的行號并且在正文引用處也明確標(biāo)注“第 72 到 86 行”這個文檔的可追溯性直接上了一個臺階。再說確定性目錄。LLM 生成目錄時只要模型參數(shù)不變、輸入不變理論上結(jié)果應(yīng)該穩(wěn)定但實(shí)際跑下來會發(fā)現(xiàn)同一個倉庫昨天生成的目錄和今天生成的目錄可能順序完全不同甚至章節(jié)編號都會變。對于個人筆記這無所謂但一旦文檔要嵌入 CI、對外發(fā)布或多人協(xié)作目錄變化就意味著鏈接失效、評審反復(fù)、diff 混亂。所謂“確定性”就是希望同一份代碼在同樣的配置下無論跑多少次產(chǎn)出的目錄結(jié)構(gòu)、章節(jié)順序、錨點(diǎn)名稱完全一致。這篇文章我會按“問題拆解——環(huán)境準(zhǔn)備——行號方案——目錄方案——實(shí)測對比——踩坑記錄”的順序展開。適合正在用 DeepWiki 做自動文檔、或者用各類 LLM 生成技術(shù)文檔并希望結(jié)果可復(fù)現(xiàn)的工程師。我會把每一步的取舍講清楚并提供可以直接抄走的腳本和配置。2. 動手之前的準(zhǔn)備環(huán)境、基線和問題定位2.1 把 DeepWiki 跑起來并鎖定版本DeepWiki 本身是開源項(xiàng)目安裝方式不復(fù)雜但有個關(guān)鍵點(diǎn)不要直接拉 latest而是要把版本鎖死。因?yàn)?LLM 生成的穩(wěn)定性不僅取決于你的 prompt還取決于代碼版本、依賴版本、甚至 Python 版本。哪怕只是依賴庫的小版本升級都可能讓你之前調(diào)好的“確定性”消失。我當(dāng)時是這么做的git clone https://github.com/your-fork/deepwiki.git cd deepwiki git checkout v0.4.2 # 記錄你實(shí)際使用的版本 python -m venv .venv source .venv/bin/activate pip install -e .注意這里強(qiáng)烈建議 fork 一份到自己倉庫因?yàn)楹罄m(xù)可能要改少量源碼。用官方倉庫再拉分支升級時沖突會比較多。鎖版本的目的是為了讓“同一倉庫 同一配置 同一模型參數(shù)”在多次運(yùn)行下具備可比性。如果不鎖版本后面做的任何優(yōu)化都很難歸因。2.2 準(zhǔn)備測試倉庫和生成基線建議選一個中等規(guī)模、結(jié)構(gòu)穩(wěn)定的倉庫來做基線測試。我用的測試倉庫大約 30 個 Python 文件、5 個目錄層級總代碼量 8000 行左右。規(guī)模太小測不出穩(wěn)定性問題規(guī)模太大又是給調(diào)試添堵。生成基線時先不做任何優(yōu)化直接跑一遍完整流程把輸出保存為baseline_v1。記住這個基線有兩個作用一是后面對比行號和目錄的改進(jìn)效果二是用來觀察“不穩(wěn)定的具體表現(xiàn)是什么”。比如我第一輪基線就跑出兩個典型問題同一個章節(jié)第二次生成時標(biāo)題從## 3.2 API 鑒權(quán)變成了## 3.2 鑒權(quán)機(jī)制代碼塊里的行號完全錯位文檔里寫“第 15 行”但真實(shí)代碼里那個函數(shù)在第 28 行。這種不確定性靠肉眼 review 很難全部發(fā)現(xiàn)所以后面我專門寫了一個校驗(yàn)?zāi)_本自動化對比兩次生成結(jié)果。2.3 確認(rèn)生成鏈路里的三個不穩(wěn)定點(diǎn)在動手優(yōu)化之前我花了半天把 DeepWiki 的生成鏈路梳理了一遍最后定位到三個關(guān)鍵不穩(wěn)定點(diǎn)第一是 LLM 采樣過程。目錄、標(biāo)題這類文本生成天然有概率性即使 temperature 設(shè)為 0某些模型在 batch 推理、并行解碼時仍會出現(xiàn)微小差異。第二是 Prompt 構(gòu)造順序。文檔的生成依賴從代碼庫提取的上下文如果上下文里文件列表的順序是動態(tài)的比如來自 set 遍歷或文件系統(tǒng)讀取順序那么最終拼接出來的 prompt 每次可能都不一樣。第三是后處理邏輯。有些章節(jié)標(biāo)題需要做 slug 化轉(zhuǎn)成錨點(diǎn)如果對中文、空格、特殊字符的處理規(guī)則不統(tǒng)一同一個標(biāo)題在不同環(huán)境下會生成不同的錨點(diǎn)。明白這三個點(diǎn)之后優(yōu)化方向就很清晰了要么在源頭把 prompt 輸入變成確定性排序要么在后處理階段覆蓋掉 LLM 的不穩(wěn)定輸出。我最終采用的是“前后夾擊”的策略兩個方案都上了。3. 代碼行號從“大概位置”到“精確錨點(diǎn)”3.1 三種行號注入方案怎么選給代碼塊加行號聽起來很簡單真正落地時會發(fā)現(xiàn)有三條路線方案一讓 LLM 自己輸出行號。也就是在 prompt 里寫“請在每個代碼塊左邊加上真實(shí)行號”。我試過效果不穩(wěn)定。模型經(jīng)常把行號寫錯尤其是遇到空行、注釋、多行字符串時它“理解”的行號和實(shí)際文件行號經(jīng)常差幾行。方案二生成后再用腳本統(tǒng)一注入行號。也就是文檔先正常生成代碼塊內(nèi)容保持原樣然后跑一個后處理腳本讀取代碼塊內(nèi)容和真實(shí)源文件比對找到對應(yīng)行號再插入到每個代碼行前面。這個方案可控性高因?yàn)橛姓鎸?shí)文件作為“唯一事實(shí)來源”。方案三基于語法樹AST精確計算函數(shù)起始行只給關(guān)鍵代碼段加行號范圍標(biāo)注。這個適合在大倉庫里做“精確導(dǎo)航”但實(shí)現(xiàn)成本高而且不同語言的 AST 規(guī)則不一樣。我最終選了方案二為主、方案三為輔。方案二解決了 95% 的問題方案三用來處理那些“同一個函數(shù)被拆成多段展示”的特殊情況。3.2 后處理腳本真實(shí)行號注入核心思路不復(fù)雜Markdown 里的每個代碼塊都會附帶語言標(biāo)簽比如python我用 Python 腳本解析文檔里的代碼塊然后把每一行代碼當(dāng)作字符串在源文件里去查找這行內(nèi)容首次出現(xiàn)的位置從而確定行號。直接看腳本import re import json from pathlib import Path def find_line_number(content: str, source_text: str, start_hint: int 0) - int: 在源文本中查找 content 首次出現(xiàn)的真實(shí)行號 idx source_text.find(content, start_hint) if idx -1: # 內(nèi)容可能跨行或被格式化退化為模糊匹配 idx source_text.find(content.splitlines()[0] if content.splitlines() else content) if idx -1: return None return source_text[:idx].count(\n) 1 def process_markdown(md_path: str, repo_root: str) - str: md Path(md_path).read_text(encodingutf-8) lines md.splitlines() output [] in_code False lang code_buf [] code_start_idx 0 def flush_code(): nonlocal code_buf, in_code if not code_buf: return # 通過代碼塊第一行注釋中的路徑信息定位源文件 path_hint for cl in code_buf: m re.match(r\s*(?:#|//|--|/\*)\s*file\s*[:\s](\S), cl) if m: path_hint m.group(1) break if not path_hint: output.extend(code_buf) code_buf [] in_code False return src_file Path(repo_root) / path_hint if not src_file.exists(): output.extend(code_buf) code_buf [] in_code False return src_text src_file.read_text(encodingutf-8) # 去掉代碼塊里的 file 注釋行避免污染展示 cleaned [cl for cl in code_buf if not re.match(r\s*(?:#|//|--|/\*)\s*file, cl)] # 計算每行的源文件行號 last_idx 0 for i, cl in enumerate(cleaned): line_no find_line_number(cl, src_text, last_idx) if line_no is None: output.append(f {cl}) else: # 補(bǔ)齊為4位行號方便對齊 output.append(f{line_no:4} | {cl}) last_idx max(0, line_no - 1) if line_no else 0 code_buf [] in_code False for idx, line in enumerate(lines): if line.strip().startswith(): if not in_code: in_code True lang line.strip()[3:].strip() code_buf [] code_start_idx idx else: flush_code() output.append(line) continue if in_code: code_buf.append(line) else: output.append(line) # 處理文檔末尾可能未閉合的代碼塊 if in_code: flush_code() return \n.join(output)這個腳本有幾個設(shè)計細(xì)節(jié)值得說明第一代碼塊里需要有定位信息我采用約定file path/to/file.py注釋。因?yàn)?AI 生成代碼時不一定能準(zhǔn)確回憶文件路徑所以在 prompt 里要求它“在每個代碼塊第一行注明該代碼來自哪個文件”。這樣腳本才能把代碼映射到真實(shí)源文件。第二查找行號時用了start_hint參數(shù)。每次找到一行之后下一次查找從這一行附近開始這樣既快又避免重復(fù)匹配同一個函數(shù)里的相同代碼行。第三對于“AI 生成的示例代碼并不完全等于源文件”的情況腳本做了降級處理如果整行找不到就取第一行來模糊匹配如果還是找不到就只輸出空格占位不讓文檔報錯。3.3 邊界情況多文件、重復(fù)代碼和動態(tài)生成內(nèi)容實(shí)際倉庫里會遇到很多讓腳本崩潰的場景我踩過的坑主要有三個第一個是同一段代碼在多個文件里重復(fù)出現(xiàn)。比如兩個文件都有def get_config():腳本搜索時可能匹配到錯誤的文件。解決辦法是把搜索范圍縮小到file指定的文件其次是查找時帶上前后幾行上下文。我的做法是拼接相鄰 2 行的內(nèi)容作為搜索鍵錯配率明顯下降。第二個是 AI 對代碼做了精簡或改寫。很多文檔為了講清原理會把真實(shí)代碼壓縮成偽代碼。這種情況下硬找行號沒有意義。我的策略是如果代碼塊和真實(shí)源文件的相似度低于 70%就直接不強(qiáng)行加行號改為在代碼塊前加一個“代碼摘要”標(biāo)注說明這是經(jīng)過簡化的示例。第三個是動態(tài)生成或臨時文件。倉庫里有些代碼是構(gòu)建腳本臨時生成的不存在于源碼中。我的處理比較簡單找不到文件就跳過不加行號同時把這類文件加入exclude列表避免每次生成都觸發(fā)告警。3.4 行號在頁面里的交互行號注入之后還需要讓它在頁面里真正可用而不只是顯示一堆數(shù)字。我做了兩件事一是在 Markdown 渲染層開啟行號樣式。由于 DeepWiki 默認(rèn)的渲染器不一定支持行號我直接在生成的 HTML 頁面上做了輕量級前端增強(qiáng)把|分隔的行號列變成>from pathlib import Path import re def safe_anchor(title: str) - str: # 統(tǒng)一錨點(diǎn)生成規(guī)則 title title.strip().lower() title re.sub(r[^a-z0-9\u4e00-\u9fa5], -, title) title title.strip(-) return title def generate_toc(repo_path: str) - str: root Path(repo_path) toc_lines [] def walk_dir(current: Path, level: int): dirs sorted([p for p in current.iterdir() if p.is_dir()], keylambda p: p.name) files sorted([p for p in current.iterdir() if p.is_file()], keylambda p: p.name) for d in dirs: # 跳過隱藏目錄、構(gòu)建目錄、依賴目錄 if d.name.startswith(.) or d.name in (node_modules, venv, __pycache__, dist, build): continue indent * level title d.name toc_lines.append(f{indent}- [{title}](#{safe_anchor(title)})) walk_dir(d, level 1) for f in files: if f.name.startswith(.) or f.suffix not in (.py, .md, .js, .ts): continue indent * level title f.stem toc_lines.append(f{indent}- [{title}](#{safe_anchor(f.stem)})) walk_dir(root, 0) return \n.join(toc_lines)這個腳本生成的是一個 Markdown 格式的目錄可以直接放在文檔開頭。因?yàn)樗羌兾募到y(tǒng)驅(qū)動的不經(jīng)過 LLM所以輸出是絕對確定的——同一份代碼無論跑多少次目錄都一樣。但這里有個重要問題只給目錄不告訴模型每個章節(jié)該寫什么模型可能還是把章節(jié)內(nèi)容串到錯誤的標(biāo)題下面。所以我還會把這份目錄作為“大綱約束”注入到生成 prompt 里明確告訴模型“必須嚴(yán)格按這個目錄順序?qū)懖荒苄略龌騽h除標(biāo)題”。4.4 方案 C緩存與增量重建當(dāng)倉庫規(guī)模變大每次全量生成文檔的時間和成本都很高。為了保持“確定性”的同時控制成本我引入了緩存機(jī)制。思路是把每次生成的文檔和源倉庫的文件哈希一起存起來。下次運(yùn)行時先對比當(dāng)前倉庫的文件哈希和上次的哈希如果某個文件沒有變化就直接復(fù)用上次生成的對應(yīng)章節(jié)不重新調(diào)用模型。這樣既保證了目錄穩(wěn)定又讓增量構(gòu)建變快。緩存鍵的設(shè)計很關(guān)鍵。我用的鍵是“文件相對路徑 文件內(nèi)容 SHA256 模型版本 prompt 模板版本”。如果只緩存文件內(nèi)容一旦你改了 prompt就會拿到舊內(nèi)容所以必須把 prompt 模板版本也加進(jìn)鍵里。import hashlib import json def hash_file(path: Path) - str: h hashlib.sha256() h.update(path.read_bytes()) return h.hexdigest() def cache_key(rel_path: str, content_hash: str, model_version: str, prompt_version: str) - str: raw f{rel_path}:{content_hash}:{model_version}:{prompt_version} return hashlib.sha256(raw.encode()).hexdigest()增量構(gòu)建的難點(diǎn)在于“父目錄和子目錄的聯(lián)動”。如果一個模塊的目錄結(jié)構(gòu)變了子章節(jié)的生成結(jié)果也需要失效。所以我做了一個簡單的依賴圖任何文件的哈希變化都會讓它在目錄樹上的所有祖先節(jié)點(diǎn)緩存失效。4.5 目錄與頁面錨點(diǎn)的聯(lián)動有了確定性目錄之后還必須確保目錄里的錨點(diǎn)和正文標(biāo)題的錨點(diǎn)對齊。LLM 生成的標(biāo)題經(jīng)過 Markdown 渲染后錨點(diǎn)規(guī)則可能和目錄生成腳本不一致。我的做法是在生成最終 HTML 之前統(tǒng)一跑一個“錨點(diǎn)規(guī)范化”步驟從目錄里提取所有標(biāo)題。對每個標(biāo)題生成一個id屬性。在正文里查找對應(yīng)標(biāo)題并寫入相同的id。如果正文里找不到某個標(biāo)題說明模型漏寫了章節(jié)這時用占位符補(bǔ)上并打一條警告日志。這樣做之后目錄點(diǎn)擊跳轉(zhuǎn)的成功率從原來的約 85% 提升到了 100%。錨點(diǎn)這個細(xì)節(jié)很多人忽略但一旦團(tuán)隊開始用目錄導(dǎo)航就會發(fā)現(xiàn)錯一個錨點(diǎn)基本等于這個章節(jié)“失蹤”了。5. 實(shí)測結(jié)果穩(wěn)定性的提升到底有多少5.1 我的驗(yàn)證方法優(yōu)化全部完成之后我用同一個測試倉庫跑了 7 輪生成記錄兩個指標(biāo)目錄重復(fù)率兩輪生成的目錄文本完全一致的比例。行號準(zhǔn)確率隨機(jī)抽取 200 個代碼塊檢查其行號與真實(shí)源文件是否一致。測試環(huán)境保持完全一致同一個 DeepWiki 版本、同一個模型 checkpoint、固定 temperature0、固定 seed、單卡推理、關(guān)閉并行采樣。5.2 優(yōu)化前后的數(shù)據(jù)對比指標(biāo)優(yōu)化前優(yōu)化后7 輪目錄完全一致占比28.5%100%目錄錨點(diǎn)點(diǎn)擊成功率85%100%代碼塊行號準(zhǔn)確率62%96.5%單輪全量生成時間22 分鐘18 分鐘加緩存后 6 分鐘需要人工 review 的文檔比例40%12%行號準(zhǔn)確率沒有到 100%原因不在腳本而在于部分 AI 生成的代碼塊是“示例代碼”不是源碼的完全拷貝。這類代碼塊按我的設(shè)計本來就不該強(qiáng)制加行號所以這 3.5% 的誤差其實(shí)屬于“合理容錯”。我對這個結(jié)果是滿意的尤其是目錄確定性做到了 100% 之后團(tuán)隊再也不用花時間核對“這一版目錄和上一版差在哪”。文檔 diff 終于變得干凈可控。5.3 優(yōu)化帶來的額外收益一個意外收獲是確定性目錄讓后續(xù)的國際化變得簡單了。之前目錄經(jīng)常變翻譯平臺上的雙語對照經(jīng)常失配?,F(xiàn)在目錄穩(wěn)定翻譯記憶庫TM的命中率提高了很多翻譯成本下降了大概四分之一。另一個收益是 CI 友好。我們把文檔生成嵌入到了 CI 流程里每次 push 后自動重新生成文檔并檢查目錄是否與上次一致。如果目錄發(fā)生變化CI 會攔截并提示開發(fā)者確認(rèn)是否有意改動目錄結(jié)構(gòu)。這個檢查在團(tuán)隊協(xié)作場景下非常有用能避免有人不小心改了一個文件名導(dǎo)致整個文檔目錄全部漂移。6. 常見問題排查與避坑實(shí)錄6.1 行號漂移代碼更新后行號全錯這是最常出現(xiàn)的問題。代碼倉庫每天都在變新增了幾行代碼之后原本的文檔行號就會往下偏移。我的處理方案是給文檔生成加上“保鮮期”——倉庫文件哈希發(fā)生變化后對應(yīng)章節(jié)自動標(biāo)記為過期下次生成時必須重新計算行號。同時在文檔頁面頂部顯示“本頁最后校驗(yàn)時間”和“對應(yīng)的 commit hash”至少讓讀者知道這份文檔是基于什么版本生成的。6.2 目錄與正文標(biāo)題不一致這類問題通常發(fā)生在模型把標(biāo)題稍作改寫之后。比如目錄里是## 3.2 API Key 管理正文里被模型寫成了## 3.2 API Keys Configuration。前面的錨點(diǎn)規(guī)范化步驟已經(jīng)能兜住大部分問題但如果模型大量改寫標(biāo)題你會看到“正文標(biāo)題和目錄標(biāo)題不一致”的告警。我的建議是把這類告警從 warning 提升為 error強(qiáng)制生成流程中斷而不是讓一個錯誤目錄混進(jìn)文檔庫。6.3 增量緩存導(dǎo)致“永遠(yuǎn)不更新”增量構(gòu)建的坑也很典型某個文件內(nèi)容變了但它的緩存鍵沒變于是生成的文檔一直是舊的。排查后發(fā)現(xiàn)是 prompt 版本號沒有在代碼修改時同步更新。后來我把 prompt 模板的內(nèi)容哈希也編進(jìn)緩存鍵里任何 prompt 修改都會自動導(dǎo)致緩存失效問題徹底解決。6.4 大倉庫超時與降級策略當(dāng)倉庫文件數(shù)超過 1000 個時一次性把全部文件內(nèi)容塞給模型是不可能的。我的策略是分層生成先對目錄樹做一次全球掃描生成每個模塊的摘要再對每個模塊單獨(dú)調(diào)用模型生成詳細(xì)內(nèi)容。如果某個模塊內(nèi)容過多就繼續(xù)往下拆分。這個策略本身就依賴確定性目錄——目錄結(jié)構(gòu)穩(wěn)定拆分點(diǎn)才能穩(wěn)定否則每次拆出來的模塊都不一樣緩存和增量也就無從談起。6.5 一個關(guān)于 token 成本的提醒確定性目錄生成雖然是代碼邏輯不消耗 LLM token但它需要讀一遍文件系統(tǒng)。對于超大倉庫文件遍歷本身可能消耗幾十秒不過相比大模型推理動輒幾分鐘這幾十秒完全值得。真正貴的是“目錄注入 prompt”之后模型可能在正文里再次生成目錄浪費(fèi)幾百 token。所以要記得在 prompt 里加一行“正文中不要再生成目錄”。7. 一些個人經(jīng)驗(yàn)總結(jié)這次優(yōu)化的核心體會是AI 生成的文檔必須要有一個“非 AI 的骨架”來兜底。代碼行號和確定性目錄本質(zhì)都是把最終結(jié)果的關(guān)鍵部分從“模型自由發(fā)揮”變成“代碼強(qiáng)制決定”。模型仍然是內(nèi)容的主要生產(chǎn)者但結(jié)構(gòu)、順序、錨點(diǎn)、行號這些“框架性信息”不應(yīng)該讓模型去決策。給正在做類似事情的同學(xué)一個建議先跑 3 次基線把兩次輸出 diff 一下你會發(fā)現(xiàn)很多你以為“沒問題”的地方其實(shí)都在悄悄變化。不要試圖在一次優(yōu)化里解決所有問題先把目錄和行號這兩個最容易讓人困惑的問題解決掉文檔的可用性就會有質(zhì)的提升。最后一個小技巧把生成文檔后的“行號準(zhǔn)確率檢查”和“目錄重復(fù)性檢查”做成一個獨(dú)立的校驗(yàn)?zāi)_本掛到 CI 上。它不是針對 DeepWiki 的特定邏輯而是通用的文檔質(zhì)量守衛(wèi)未來即使換用其他生成工具這套思路也能直接復(fù)用。