背景
最近在用 deepwiki-open 给内部的 Java 项目生成 wiki,发现一个很明显的问题:生成的 wiki 页面里引用的代码行号经常不准确,看起来是 LLM 根据上下文自己推算的。
比如一个函数明明在第 503 行,生成出来的 wiki 里可能标注成第 510 行甚至更离谱的数字。
之前我在 DeepWiki 一个常用 RAG 应用的开发流程 里分析过它的整体流程,本文主要聊聊我们在实际使用中遇到的两个问题以及对应的优化方案。
问题分析:LLM 为什么算不对行号
在我们的优化版本中,已经使用 tree-sitter 基于 AST 将代码进行拆分存入了本地的向量数据库。
第一版存储的 chunk 格式是这样的:- <file path="src/main/java/com/example/Client.java">
- <chunk start_line="503" end_line="581">
- package com.example;
- import cn.hutool.core.util.ZipUtil;
- import com.google.common.collect.Lists;
- import com.google.protobuf.ByteString;
- // ... 后面的代码
- </chunk>
- </file>
复制代码 看起来我们已经告诉 LLM 这段代码从第 503 行开始到第 581 行结束了,但问题在于:chunk 内部的代码是原始文本,没有行号标记。
当 LLM 需要引用某个具体函数的行号时,它必须从 start_line=503 开始,自己数第几行是哪个函数。
这对 LLM 来说太难了——众所周知 LLM 不擅长数学计算,让它去数几十行代码然后算出 503 + 偏移量 = 实际行号,幻觉就不可避免了。
这就好比你让一个文科生做加法题还不让用计算器,虽然能算但准确率堪忧。
优化一:给代码加上行号前缀
既然 LLM 算不准,那最好的办法就是直接把结果给它,让它只需要"读"而不需要"算"。
改动思路
核心改动很简单,在把 chunk 内容发给 LLM 之前,给每一行代码加上实际行号前缀:- def _add_line_numbers(text: str, start_line: int) -> str:
- """给代码文本的每一行添加行号前缀"""
- return '\n'.join(
- f"{start_line + i}. {line}"
- for i, line in enumerate(text.split('\n'))
- )
复制代码 优化前后对比
优化前优化后chunk 内容原始代码文本每行带行号前缀LLM 行为从 start_line 推算偏移量直接读取行号准确率经常偏差 5-20 行基本准确优化前发给 LLM 的数据:- <chunk start_line="503" end_line="581">
- package com.example;
- import cn.hutool.core.util.ZipUtil;
- import com.google.common.collect.Lists;
- </chunk>
复制代码 优化后发给 LLM 的数据:- <chunk start_line="1" end_line="44">
- 2.
- 3. import cn.hutool.core.util.ZipUtil;
- 4. import com.google.common.collect.Lists;
- 5. import com.google.protobuf.ByteString;
- </chunk>
复制代码 LLM 现在要引用 ZipUtil 的导入行,直接看到前缀 3. 就知道是第 3 行,不需要做任何计算。
具体改动文件
一共改了 4 个文件:
1. api/websocket_wiki.py — 添加 _add_line_numbers() 工具函数,在构建 chunk 内容时加上行号前缀:- if start_line is not None and end_line is not None:
- numbered_text = _add_line_numbers(doc.text, start_line)
- doc_parts.append(
- f'<chunk start_line="{start_line}" end_line="{end_line}">\n{numbered_text}\n</chunk>'
- )
复制代码 2. api/websocket_wiki.py 的 prompt 部分 — 更新 指令,告诉 LLM 直接读取行号前缀而不是自己计算:- "<line_number_rules>"
- "Each line in the code context is prefixed with its actual line number (e.g., '100. code here'). "
- "When citing source lines, read the line numbers directly from these prefixes. "
- "Do not count or calculate line numbers yourself. "
- "</line_number_rules>"
复制代码 Token 成本
每行增加约 4-6 个字符(比如 503. ),一个典型的 chunk 30-40 行,大概增加 150 字符。10-20 个 chunks 总共增加约 1500-3000 字符(约 500-1000 tokens),成本基本可以忽略。
优化二:基于 Proto 文件生成确定性目录
第二个问题是 wiki 的目录结构。
DeepWiki 默认的做法是把 repo 的目录树和 README 丢给 LLM,让它自由发挥来生成 wiki 目录(虽然有一些限制提示词,比如输出目录结构的大概要求)。这在通用场景下是合理的,但对我们的内部 Java 项目来说效果不好。
原因很简单:我们所有的业务都是围绕着 gRPC 接口来的,理想的 wiki 目录应该是按 Service 和 RPC 方法来组织的,而不是让 AI 自由发挥出一堆"Architecture Overview"、"Getting Started" 之类的通用章节。
改动思路
写代码读取 repo 里所有的 *.proto 文件,解析出所有的 Service 和 RPC 接口列表,然后直接构建出确定性的目录结构给前端,绕过 LLM 的目录生成步骤。
具体流程:
- 扫描 repo 里所有 .proto 文件
- 用正则解析出 package、service、rpc 定义
- 构建固定格式的 WikiStructure JSON
- 前端检测到 proto 文件存在时,调用这个接口替代 LLM 生成
核心代码:proto_parser.py
新增了一个 api/proto_parser.py 文件,主要做三件事:
扫描 proto 文件:- def find_proto_files(repo_path: str, excluded_dirs=None) -> List[str]:
- """遍历 repo 目录,返回所有 .proto 文件路径"""
- skip = set(DEFAULT_EXCLUDED_DIRS) # 排除 vendor、node_modules 等
- proto_files = []
- for root, dirs, files in os.walk(repo_path):
- dirs[:] = [d for d in dirs if d not in skip]
- for f in files:
- if f.endswith(".proto"):
- proto_files.append(os.path.join(root, f))
- proto_files.sort()
- return proto_files
复制代码 解析 proto 内容:- _RE_PACKAGE = re.compile(r"package\s+([\w.]+)\s*;")
- _RE_RPC = re.compile(
- r"rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)",
- )
复制代码 通过平衡大括号匹配来提取 service block,再用正则提取每个 RPC 方法的签名,包括方法名、请求类型、响应类型以及是否是 streaming。
构建 wiki 目录结构:
生成的目录包含 3 个固定章节 + 每个 Service 一个独立章节:
章节内容Overview项目总览System Architecture系统架构Core FeaturesgRPC 接口汇总{ServiceName} Service每个 RPC 方法一个子页面每个 RPC 方法的页面标题直接用方法签名,比如 GetOrder(GetOrderRequest) returns (GetOrderResponse),非常清晰。
前端改动:page.tsx
在 src/app/[owner]/[repo]/page.tsx 里新增了一个检测逻辑:- // 检测 repo 是否包含 proto 文件
- // 如果有,调用 /api/proto/wiki_structure 获取确定性目录
- // 如果失败,fallback 到原来的 LLM 生成方式
复制代码 前端的核心逻辑是:
- 先尝试调用 proto 解析接口获取确定性目录
- 如果 proto 接口返回了有效结构,直接使用(跳过 LLM 目录生成)
- 并发生成每个页面的具体内容(最多 5 个并行请求)
- 如果 proto 接口失败,fallback 到原来的 LLM 生成流程
这样做的好处是:目录结构 100% 准确,不会出现 LLM 瞎编目录的情况,同时还省了一次 LLM 调用的成本。
确定性 vs 不确定性:什么该交给 AI
这两个优化背后其实是同一个思路:把确定的东西明确告诉 AI,不确定的才让 AI 来发挥。
类型内容处理方式确定的代码行号直接给 LLM 标注好确定的gRPC 接口列表、目录结构代码解析,不经过 LLM不确定的函数功能解释交给 LLM 归纳不确定的项目架构分析交给 LLM 总结不确定的代码关联关系交给 LLM 推理LLM 非常擅长理解、归纳和总结,但不擅长精确计算和结构化数据的生成。把它们分开处理,各取所长,效果就好很多了。
总结
这篇文章分享了我们在基于 DeepWiki 做内部项目 wiki 生成时的两个优化:
- 行号前缀:给代码 chunk 的每一行加上实际行号,让 LLM 直接读取而不是自己推算,token 成本几乎可以忽略但准确率大幅提升。
- 确定性目录生成:通过代码解析 proto 文件直接构建目录结构,绕过 LLM 的自由发挥,保证目录 100% 准确。
核心经验就一句话:需要定制自己的项目,尽量不要用通用的方案,不然就只是可用,但不精通。类似于现在的 OpenClaw,通用方案大家都能用,但真正好用的一定是针对你自己场景深度优化的。
对于确定的内容要明确告知 AI,不要让它自行发挥去推理,特别是和逻辑计算相关的,不然幻觉很严重。而不确定的、需要归纳总结的主观内容,则非常适合交给 AI 来输出。
Blog
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |