很多人第一次接触 DeepWiki 时都会觉得这东西简直是仓库文档化的救星丢一个 GitHub 仓库链接进去就能自动生成结构完整的 wiki。但真正在项目里用起来之后问题就来了自动生成的文档每次跑出来的结果可能都不一样代码块没有行号别人在评论里想引用某个具体位置都说不清楚。我这次做的优化就是把这两个最折磨人的点一次性解决掉——给代码块加上行号同时让目录生成结果变得确定可控。先说适合谁看。如果你正在做 AI 辅助文档生成、自动化 wiki 管线或者只是用类似工具给自己的仓库做 README 级文档这篇内容都值得看完。里面涉及的技术点不复杂但坑不少我会把每一步怎么改、为什么这么改、改完踩过哪些雷都讲清楚直接抄作业就行。1. 内容整体设计与思路拆解1.1 痛点定位行号和确定性目录到底解决什么问题先聊一个很现实的场景。我在维护一个中型开源库代码量不大但函数拆得细文档里引用的代码片段特别多。之前生成的 wiki 页面里代码块就是一坨纯文本没有行号也没有逐行锚点。同事在 Code Review 时想指出“第 47 行那个边界条件处理得有问题”只能发截图或者自己数行数。次数一多协作效率就下来了。所以把代码行号加进去表面上看只是视觉上的小改动实际上解决的是“引用定位”的问题——不管是人在讨论还是后续再用 AI 去读文档有行号都意味着可以精确引用。目录生成这块更麻烦。DeepWiki 这种工具在生成目录时通常依赖文件系统遍历和子模块解析而这两者的顺序天然不是稳定的。文件系统返回目录项的顺序由底层实现决定不同操作系统、不同文件系统类型甚至同一个系统不同挂载参数返回顺序都可能不一样。于是出现了一个很抽象的现象同一个仓库在 macOS 上跑生成的 wiki和 Linux 上跑生成的 wiki目录排序完全不同。这对 CI 来说简直是灾难每次构建都会产生无意义的 diffRelease 产物里到处是无关变更审查者根本分不清哪个改动是真实的。所以这次优化的核心思路就两条给代码块行号弄一个稳定可靠的渲染方案给目录生成加一套确定性的排序规则保证无论谁跑、在哪跑结果都一样。1.2 方案选型为什么没选简单粗暴的前端渲染一开始我想得很简单行号这种功能直接用 JavaScript 在浏览器里渲染不就行了highlight.js 或者 Prism 都有行号插件几行代码搞定。但仔细想了一下就放弃了原因有两个。第一DeepWiki 生成的文档是 markdown 源文件加渲染页面的组合很多使用者包括我自己会直接拿 markdown 源文件去做二次处理比如喂给其他工具、批量转换格式、推到其他文档平台。如果行号只是前端渲染时加上的装饰那么 markdown 源文件里仍然是光秃秃的代码块等于问题没有真正解决。第二确定性目录生成本来就是一个偏后端、偏管线的需求如果行号也依赖浏览器端脚本那对文档的消费者就提出了额外要求——必须启用 JavaScript必须加载对应插件。对于 wiki 这种要长期存档、多端访问的内容来说这种依赖越少越好。所以我最终选择在生成阶段就把行号写进代码块的原始 markdown 里。具体做法是用一个 Python 脚本在生成后的 markdown 文件基础上做后处理识别代码块位置解析语言类型然后按行拆分、补上行号列。每一个代码块的格式都统一为数字行号 两空格分隔 原代码内容这样不管文档被扔到哪里代码块的文本本身就自带行号信息不依赖渲染环境。代价是源代码块如果特别长行号会占据一定视觉宽度但实测下来影响不大而且可以配置只对超过 4 行的代码块加行号避免短代码块显得啰嗦。1.3 预期目标一次改动多处受益这次优化完成后我给自己定了三个验收指标所有包含代码块的 wiki 页面行号都是文本一部分浏览器里能直接看到复制代码时行号不会混进去。同一次构建在任意环境下重复执行生成的目录树分毫不差。markdown 源文件保持干净没有临时状态可以用 git diff 正常追踪变更。从后面实际结果看三个指标都达成了而且额外收获了一个好处因为有行号了我可以直接在文档里写“见 foo.py 第 12-15 行”这样的交叉引用整个 wiki 的可读性提升了一个档次。2. 核心细节解析与实操要点2.1 行号渲染的方案拆解文本注入还是渲染层改造先说行号这块最核心的决策点。我调研过三种实现方式各有优劣按照我刚才提到的原则最终选了文本注入。第一种是渲染层改造也就是修改 wiki 页面模板在代码块渲染时动态计算行号。这种方式最直观但问题在于 wiki 页面模板通常要兼顾很多页面类型改动模板的风险面很大而且代码块经过了代码高亮插件的处理DOM 结构已经被改写行号算起来容易出边界问题。另外这种方式生成的行号是“虚拟”的打印页面或者复制代码时直接消失实用性有限。第二种是 CSS 计数器方案用伪元素在每行代码前面显示序号。相比 JavaScript 方案它不依赖脚本但一样是渲染层的事情并且对多行代码的换行处理要特别小心稍微有点缩进的代码就很容易出现行号与内容错位。我记得早期看过一些用 this 方案做的代码高亮主题代码一旦用了 tab 缩进行号就开始歪。第三种就是现在采用的文本注入方案。生成 markdown 后处理阶段解析每个代码块把行号和代码合并成一个整体文本。这个方案最土但最可靠而且它不挑渲染器、不挑主题、不挑终端无论你的文档是用 GitHub 渲染、本地 markdown 工具打开还是直接 cat 源码都能看到行号。实际实现时我写了一个大概 80 行的 Python 脚本核心是识别以三个反引号开头的代码块记录语言类型然后对块内内容按行拆分。有一点要注意markdown 的代码块是可以有缩进的所以在判断代码块结束时不是只看三个反引号还要结合前面的空白符。为了稳妥我还处理了代码块内部可能出现的反引号字符串防止误截断。2.2 确定性目录生成的排序规则设计目录生成的问题比行号复杂不少。DeepWiki 生成目录的机制我仔细读了一下源码核心是调用 Python 的os.walk来遍历仓库文件然后按某种方式把文件组织成层级结构。问题就出在这个os.walk上它返回的目录项顺序是底层文件系统的顺序而底层顺序往往不是字典序甚至在不同系统上表现不一致。明确这个根因之后解决思路就很清晰在遍历之后加一道排序并且必须用稳定且不依赖环境的排序键。具体来说我用字典序作为唯一排序规则目录和文件都按完整路径排序目录排在文件前面为了符合阅读习惯同级目录项之间按字符串排序。这里面有个细节容易忽略路径字符串的排序粒度。如果直接对完整路径src/module/detail.py做字符串比较那么src/module2/和src/module/的相对位置受字符2的 ASCII 值影响结果可能与直觉不符。所以我实现排序时是先按层级拆分成列表再逐层比较这样目录结构相同的部分会先对齐后缀字符只影响同一父目录下的顺序。我设计了一个简单的规则文件路径按/切分每一级都作为独立层级层级比较时目录类型的项排在文件类型之前同一层级下用字符串的字典序排列全部分层比较完后如果仍然平手用完整路径字符串做最终兜底比较。这套规则在任何常见操作系统上表现都是一致的因为只依赖字符串比较和路径分隔符这两个东西在任何 Python 3 环境中都一样。2.3 影响范围与兼容性权衡在这个项目里行号和目录生成看起来是两个孤立功能其实互相之间是有影响的。比如目录结构变了wiki 页面的代码块也可能跟着移动如果行号是后处理加的那么每次目录重排后都要重新跑一遍行号脚本顺序必须固定下来。我把整个流程固定成三步拉取仓库、跑原始生成得到无行号的 markdown确定性排序目录调整文件之间的组织关系对所有 markdown 文件统一执行行号注入脚本。这个顺序一旦确定就不再改动否则每次构建产品都可能漂移。实际操作时我把这三步写成一条 make 命令保证 CI 和本地开发调用的是同一套逻辑。这一点非常重要因为 AI 生成工具的迭代速度太快手动的临时操作很容易被覆盖。3. 实操过程与核心环节实现3.1 行号注入脚本的完整实现与参数说明先贴我实际在用的这段脚本Python 3.8 以上就能跑不需要任何第三方依赖。它对单个 markdown 文件做行号注入支持--min-lines参数默认 1表示所有代码块都加行号。#!/usr/bin/env python3 import argparse import re FENCE_RE re.compile(r^(|~~~)(\w*)\s*$) def add_line_numbers_to_markdown(text: str, min_lines: int) - str: lines text.splitlines(keependsTrue) result [] in_code False fence lang # 用循环手动遍历因为代码块内部内容不应该被再次解析 i 0 while i len(lines): line lines[i] if not in_code: m FENCE_RE.match(line.rstrip(\n)) if m: in_code True fence m.group(1) lang m.group(2) result.append(line) i 1 code_start len(result) code_lines [] while i len(lines) and not FENCE_RE.match(lines[i].rstrip(\n)): code_lines.append(lines[i]) i 1 if i len(lines): closing lines[i] i 1 else: closing if len(code_lines) min_lines: width len(str(len(code_lines))) numbered [] for idx, code_line in enumerate(code_lines, start1): numbered.append(f{idx:{width}} {code_line}) result.extend(numbered) else: result.extend(code_lines) result.append(closing) continue result.append(line) i 1 return .join(result) def main(): parser argparse.ArgumentParser() parser.add_argument(file, helpmarkdown file path) parser.add_argument(--min-lines, typeint, default1) args parser.parse_args() with open(args.file, r, encodingutf-8) as f: content f.read() new_content add_line_numbers_to_markdown(content, args.min_lines) if new_content ! content: with open(args.file, w, encodingutf-8) as f: f.write(new_content) print(fupdated: {args.file}) else: print(funchanged: {args.file}) if __name__ __main__: main()特别说明两点。第一为什么我同时匹配了 和 ~~~ 两种围栏因为 DeepWiki 生成的 markdown 在某些模板里会用波浪线做围栏如果只处理一种另一种就会漏掉。第二宽度计算width len(str(len(code_lines)))是为了行号对齐。比如代码有 120 行行号位数是 3所有行号都按 3 位宽度右对齐在浏览器里看起来就非常整齐。左对齐其实也行但右对齐更符合代码行号的阅读习惯。3.2 批量处理全部 markdown 文件的命令与思路单文件脚本写完之后需要批量处理整个 wiki 目录。我用 find 加 while 循环逐个调用脚本并利用--min-lines控制哪些代码块加行号。find wiki/ -name *.md -type f | while read -r f; do python3 add_line_numbers.py $f --min-lines 4 done这里有个小经验把min_lines设成 4小于等于 3 行的代码块不加行号。这样像单行命令、三行 API 配置这类短代码保持原有清爽格式超过 4 行的真正需要定位的代码块才加行号。实际用下来这个阈值很合理。跑完之后可以用 git diff 看一下效果。如果某个页面只改了代码块格式没有其他变化说明脚本没有破坏原有内容。我第一次跑的时候发现有些文件即使代码块完全一样也被重复更新后来发现是脚本把行尾空白符也算进 diff 了。我调整了字符串比较逻辑只在实际行号或代码内容变化时才写回文件这个问题就消失了。3.3 确定性目录的生成逻辑与排序算法实现目录确定性这块我写了一个独立脚本输入仓库路径输出 JSON 格式的目录树。因为最终还是要给 wiki 渲染器用所以 JSON 的结构就按层级嵌套来设计。#!/usr/bin/env python3 import json import os import sys from pathlib import Path EXT_SORT_ORDER {md: 0, rst: 1, txt: 2, py: 3, js: 4, ts: 5, go: 6} def build_tree(root: Path): entries [] try: children list(root.iterdir()) except PermissionError: return {name: root.name, type: dir, children: []} dirs, files [], [] for child in children: if child.name.startswith(.git): continue if child.is_dir(): dirs.append(child) else: files.append(child) def sort_key_dir(p: Path): return (0, p.name.lower(), p.name) def sort_key_file(p: Path): ext p.suffix.lstrip(.).lower() type_rank EXT_SORT_ORDER.get(ext, 100) return (1, type_rank, p.name.lower(), p.name) dirs.sort(keysort_key_dir) files.sort(keysort_key_file) for d in dirs: entries.append(build_tree(d)) for f in files: entries.append({ name: f.name, type: file, path: str(f.relative_to(Path.cwd())), }) return {name: root.name, type: dir, children: entries} if __name__ __main__: root Path(sys.argv[1]) tree build_tree(root) print(json.dumps(tree, indent2, ensure_asciiFalse))排序键的解释sort_key_dir返回的元组中第一个元素 0 表示目录类型保证目录整体排在文件前面然后是小写名称比较最后是原始名称兜底。文件排序中我用扩展名做了一个优先级表md和rst这类文档优先代码文件其次其他类型排最后。这个顺序在实际目录里看起来非常自然因为 wiki 的目录里文档往往是主体让文档排在前面就不用上下翻滚找 entry 文件了。这么排之后同样的仓库在任何操作系统上生成的 JSON 都完全一致我实测在 Windows、macOS、Linux 的容器里分别跑过输出 md5 一样。3.4 把行号和目录生成集成到构建流程中单独能跑还不够项目建设最忌讳的就是“一堆工具但没人记得怎么用”。我用一个 Makefile 把整个流程固化下来确保任何人只需要执行make wiki就能一气呵成地拿到最终产物。.PHONY: wiki wiki: python3 scripts/generate_wiki.py python3 scripts/generate_tree.py $(REPO_PATH) docs/tree.json find wiki/ -name *.md -type f | while read -r f; do \ python3 scripts/add_line_numbers.py $$f --min-lines 4; \ done .PHONY: verify verify: git diff --exit-code cat docs/tree.json | python3 -m json.tool /dev/nullverify这一步的作用是让 CI 能在合并前直接检查如果git diff --exit-code非零说明本次构建产生了未提交的漂移CI 直接失败提醒开发者跑一次make wiki再提交。这个设计在多人协作时特别重要因为很容易出现“本地生成还挺正常但 commit 里漏了更新文件”的情况。4. 常见问题与排查技巧实录4.1 行号注入后复制代码带行号要怎么处理这是我做完行号功能后被吐槽最多的问题。有些人从网页复制代码时行号会一起进剪贴板粘贴到编辑器里就多了一列数字体验很不好。这个问题在纯文本注入方案里无法完全避免但我找到两个缓解办法。一个办法是给每个代码块加一个复制按钮点击时通过 JS 把原始代码去掉行号列写入剪贴板。这个方案需要改渲染层我一开始不想动模板但后来发现很多 wiki 框架本身就有复制代码按钮的插件直接配置一下就行。另一个办法是让行号列的宽度保持固定并把行号和代码用两个空格隔开这样即使复制过去也容易用编辑器批量处理去掉行号。实操上我用了一个 CSS 技巧给行号列加user-select: none这样在浏览器里选中代码时行号不会被选中。虽然 markdown 源文件里行号是文本但渲染出来后浏览器能识别哪些内容不可选中复制体验会好非常多。考虑到不同框架的实现差异我给出的建议是如果你的 wiki 是自部署的优先走user-select: none方案如果是托管平台比如直接用 GitHub wiki那就只能接受行号存在源文件里的事实但从团队协作角度看这点代价完全值得。4.2 目录顺序变了但文档链接失效怎么办目录排序变了之后我遇到过一个很典型的问题之前生成的 wiki 页面里src/module/detail.py和src/other/start.py的相对顺序在我的新规则下发生了互换然后页面里所有指向这两个文件的相对链接全部失效。排查下来发现链接失效是因为生成的 markdown 文档里使用的路径仍是旧的相对路径而目录树重组后文件层级发生了变化。解决这个问题的办法是在目录排序后增加一个链接校准的步骤。我写了个脚本扫描所有 markdown 文件提取形如[text](path)的链接再用确定性排序后的新路径替换旧路径。替换的逻辑很简单在排序前记录每个文件路径到 URL 的映射排序后重新生成映射再把文档里所有引用路径都重定向到新位置。这一步做完之后链接失效问题彻底消失。这个环节给我的教训是目录结构一旦要改变不能只关注目录树本身所有依赖路径的引用都要一起更新。尤其是 wiki 这种互相引用密集的内容一个小顺序调整可能联动几十个文件手工改是不可能的必须脚本化。4.3 不同环境排序结果不一致的排查过程最早发现排序不稳定是在 CI 环境里。一个开发机的 Ubuntu 生成的tree.json和另一个 macOS 生成的 SHA256 对不上我第一反应是文件系统顺序问题但定位到真实原因耗时很久。这里我走了不少弯路分享一下教训。我最初怀疑是os.walk返回顺序不同于是试着在遍历后立即使用内置sorted()但结果仍然不一致。后来发现有两个隐藏问题一是路径分隔符Windows 上是反斜杠\Linux 上是正斜杠/直接用split(/)处理路径Windows 下根本切不开二是环境变量里存在特定前缀的路径干扰导致两个系统拿到的绝对路径基准不同。修复方案很简单统一用Path对象操作路径排序前把__file__的父级去掉始终基于仓库根目录计算相对路径。这样不仅解决了排序不一致的问题还让生成结果从绝对路径依赖中解放出来整个 JSON 更干净。这里也给用os.walk排查问题的人提个醒不要忽视路径分hunter否则跨平台时很容易掉坑。4.4 排查工具与速查表调试这类问题我用得最多的组合是git diff --stat加find ... -newer。前者看整体变更范围后者精确定位一段时间内被改动的文件配合使用可以快速圈定异常范围。这里有一张排查速查表是我优化过程沉淀下来的症状可能原因排查命令/方法解决方案目录排序在不同系统上不一致文件系统遍历顺序不同、路径分隔符差异对比 Linux/macOS 下tree.json的差异统一用Path对象基于相对路径排序代码块漏加行号围栏符号不匹配 vs ~~~检查 markdown 原文的围栏字符正则同时匹配两种围栏重复生成时 diff 变大排序无确定性两次构建后git diff看差异确定性排序规则 CI 校验链接失效目录顺序改变导致相对路径错位扫描 md 内链提取所有](...)排序后统一更新引用路径复制代码时行号混入文本注入方案本身特性浏览器测试复制行为渲染层加user-select: none或复制按钮5. 扩展方向把“确定性”变成通用组件做完了这一轮优化之后我在想一个问题能不能把“确定性生成”这件事做成一个通用组件而不是只对 DeepWiki 生效。现在很多 AI 生成文档的管线遇到的最大问题就是“非线性”模型推理有随机性文件遍历有随机性甚至并发执行时写入顺序也会有随机性。这些随机性叠加起来导致每一次生成产物都面目全非。我目前的思路是把生成过程拆成几个独立的确定性阶段输入快照、排序器、渲染器、校验器。输入快照保证每次处理后拿到的都是同一种输入排序器负责把所有无序项变成有序渲染器只在输入变时改变输出校验器在最后检查是否有非确定性漂移。这套思路不仅适用于 wiki 生成所有需要自动化产出的内容管线都能套用。我在项目里先把这种模式起名为“确定性生成管线”它真正的价值在于让 AI 生成的文档具备版本控制能力可以像代码一样 Review、回滚、追责。如果你的项目也需要多人协作文档、需要 CI 自动发布手册这个方向很值得继续做下去。就我个人这一轮实际操作的体会来说纯粹的“能用”和“能稳定复现”完全不是一个量级的体验。给代码块加行号这件事别看技术上门槛不高但做完之后团队在文档里定位问题、讨论问题的效率提升是肉眼可见的。而确定性目录生成更是让整个 wiki 从“一种随机艺品”变成了“一个可维护的工程产物”。最后再分享一个小技巧本文提到的行号注入脚本里对代码块长度做阈值控制这个思路在你处理任何批量格式化任务时都用得上——不是所有内容都需要统一风格合适的粒度带来的是更好的可用性。
