1. 幻觉代码到底是怎么被"喂"出来的
AI 编程助手写代码这件事,现在几乎每个团队都在用。但只要你用得稍微深一点,就一定遇到过这种情况:它给你一段看起来无比合理的代码,import 了一个根本不存在的包,调用了一个签名完全对不上的方法,或者引用了一个三年前就已经废弃的 API。你复制粘贴运行,报错,回头找它,它非常诚恳地道歉,然后给你另一段同样错误的代码。
这不是模型"笨",这是信息源的问题。
大语言模型在编程场景下的知识来源,本质上就两块:训练时"背"下来的参数化记忆,以及推理时通过上下文窗口"看"到的外部信息。前者是模糊的、有截止日期的、会混淆版本的;后者才是精确的、可验证的、实时的。所谓"幻觉代码",绝大多数情况下就是模型在用第一块知识硬答第二块知识该回答的问题。
我举个特别典型的例子。假设你问助手:"用某个云厂商的对象存储 SDK 上传一个文件,并设置生命周期规则。"模型在训练数据里见过这个 SDK 的 v2 版本、v3 版本、甚至某个已经下线的预览版本,它们的类名、方法链、参数顺序都不一样。模型不会告诉你"我不确定你用的是哪个版本",它会挑一个它"印象最深"的版本给你写出来。结果就是你拿着 v3 的文档去跑 v2 的代码,或者反过来。
这个问题的根源,不在于模型能力不够,而在于知识供给的方式不对。你把一本 5000 页的、包含 20 个版本文档的说明书塞进它脑子里,然后指望它在回答时自动挑出正确的那一页——这本身就是在为难它。
所以真正要解决的问题是:怎么在模型生成代码的那一刻,把"当前项目实际使用的那个版本、那个框架、那个云服务"的精确文档,精准地送到它面前。
这就是 MCP(Model Context Protocol)这类协议,以及各大云厂商知识检索架构要解决的核心命题。标题里提到的"Google 官方文档 MCP"和"三大云厂商知识检索架构",本质上都是在做同一件事:把权威文档变成模型可以实时查询的外部知识源,而不是让它靠记忆瞎猜。
这篇文章我会从从业者的视角,把这件事拆开讲透。包括 MCP 到底解决了什么、文档检索架构应该怎么设计、三大云厂商在这件事上的思路差异、以及我自己在搭建这类系统时踩过的坑和总结出来的实操方法。不管你是刚开始接触 AI 编程助手的开发者,还是正在给团队搭建内部知识检索系统的工程师,应该都能从里面找到能直接抄作业的东西。
2. MCP 协议到底解决了什么问题
2.1 从"塞上下文"到"按需查询"的范式转变
在 MCP 出现之前,大家解决文档供给问题的主流做法是"塞上下文"。也就是在提问的时候,把相关文档片段拼接到 prompt 里一起发给模型。RAG(检索增强生成)本质上就是这个思路的工程化版本:先把文档切片、向量化、存进向量数据库,用户提问时检索出最相关的几个片段,拼进上下文。
这个方案能用,但有几个绕不过去的坎。
第一个坎是检索精度。向量检索是基于语义相似度的,它擅长找"意思相近"的内容,但不擅长找"精确匹配"的内容。你问"某个方法的第三个参数是什么",向量检索可能给你返回一段讲这个方法整体用法的概述,而不是那个参数的精确定义。对于代码场景来说,参数类型、方法签名、返回值这些信息,差一个字就是编译错误。
第二个坎是上下文预算。你不可能把整个文档库都塞进上下文窗口。检索出来的片段数量有限,而代码问题往往需要跨多个文档页面才能回答完整。比如"怎么配置一个带自定义域名的 HTTPS 负载均衡",这个问题可能涉及负载均衡器文档、证书管理文档、DNS 配置文档三个部分,向量检索很难一次性把这三块都精准捞出来。
第三个坎是时效性。文档是会更新的,API 是会变的。向量数据库里的内容需要定期重新索引,而这个更新周期往往跟不上文档的实际变化速度。
MCP 的思路和 RAG 有本质区别。RAG 是"我猜你需要什么,提前帮你捞出来";MCP 是"我告诉你有哪些工具可以用,你需要什么自己去查"。
打个比方。RAG 像是你进了一家餐厅,服务员根据你的口味偏好,直接给你端上来几个菜。MCP 像是你进了一家自助餐厅,服务员告诉你"左边是热菜区,右边是冷菜区,后面是甜品区",你想吃什么自己去拿。
这个区别在代码场景下特别关键。因为代码问题往往是多轮、递进、需要精确信息的。模型需要能够主动发起查询:"我要查这个类的构造函数签名"、"我要查这个方法在 v3 版本里的参数列表"、"我要查这个错误码的含义"。这些查询是模型根据当前对话上下文动态决定的,而不是提前检索好的。
2.2 MCP 的核心抽象:工具、资源与提示
MCP 协议本身并不复杂,它的核心抽象就三个:工具(Tools)、资源(Resources)、提示(Prompts)。
工具是模型可以主动调用的函数。比如"搜索文档"是一个工具,"获取某个页面的完整内容"是一个工具,"列出某个 API 的所有方法"也是一个工具。模型在生成回答的过程中,可以决定调用哪个工具、传什么参数,然后根据返回结果继续生成。
资源是模型可以读取的数据。和工具的区别在于,资源是被动的、只读的,模型不能通过资源去"执行"什么操作,只能读取内容。比如某个文档页面的原始 Markdown 内容、某个 API 的 OpenAPI 规范文件,这些都可以作为资源暴露给模型。
提示是预定义的模板,用来引导模型在特定场景下的行为。比如"代码审查"是一个提示模板,"API 迁移指南"是另一个。提示的作用是把领域专家的经验固化下来,让模型在面对特定任务时能够按照最佳实践来操作。
对于文档检索这个场景来说,最核心的是工具。你需要设计一组工具,让模型能够:
- 搜索文档(输入查询词,返回相关页面列表)
- 获取页面内容(输入页面 ID,返回完整内容)
- 列出某个 API 的所有端点
- 获取某个端点的详细参数说明
- 检查某个 API 在特定版本中的可用性
这组工具的设计质量,直接决定了整个系统的效果。工具设计得好,模型就能像一个有经验的开发者一样,一步步查到它需要的信息;工具设计得不好,模型就会在各种无关结果里打转,最后还是靠猜。
2.3 为什么 MCP 比直接塞文档更靠谱
我实测下来,MCP 方案相比直接塞文档,在代码场景下有四个明显的优势。
第一是精确性。当模型通过工具查询"某个方法的参数列表"时,它拿到的是结构化的、精确的数据,而不是一段模糊的自然语言描述。这个区别在生成代码时是决定性的。模型看到的是upload(bucket, key, file, options)这样的精确签名,而不是"upload 方法接受桶名、键名和文件对象"这样的描述。
第二是可验证性。模型通过工具查到的每一条信息,都有明确的来源。如果生成的代码有问题,你可以回溯到是哪个文档页面提供了错误信息,或者模型在哪个环节理解错了。这比"模型记忆里的某个版本"要可追溯得多。
第三是时效性。文档更新后,只需要更新工具背后的数据源,模型下次查询时拿到的就是最新内容。不需要重新训练模型,也不需要重新索引向量数据库。
第四是上下文效率。模型只在需要的时候才去查询,不需要的信息不会占用上下文窗口。这对于处理复杂问题特别重要,因为复杂问题往往需要多轮查询,如果每轮都塞一大堆文档进去,上下文很快就满了。
当然,MCP 方案也不是没有代价。最大的代价是延迟。每次工具调用都是一次网络请求,模型需要等待返回结果才能继续生成。如果一个问题需要查询五六次,累积的延迟就会比较明显。这个后面讲优化的时候会详细说。
3. 文档检索架构的核心设计要点
3.1 文档预处理:从 HTML 到结构化知识
不管你用不用 MCP,文档检索系统的第一步都是把原始文档变成机器可读、可检索的结构化数据。这一步做得好不好,直接决定了后面所有环节的上限。
大多数官方文档都是以 HTML 形式发布的。直接拿 HTML 去检索,效果会很差,因为 HTML 里充满了导航栏、侧边栏、页脚、广告、相关链接这些噪音。你需要先把正文内容提取出来。
我一般用这样的流程:
# 文档预处理的核心步骤示意 # 1. 抓取原始 HTML # 2. 提取正文内容(去掉导航、侧边栏、页脚) # 3. 转换为 Markdown(保留代码块、表格、标题层级) # 4. 按语义单元切片(不是按固定字数切) # 5. 为每个切片生成元数据(所属页面、API 名称、版本号等) # 6. 存入检索索引这里有几个关键决策点。
切片策略是最重要的。很多人习惯按固定字数切片,比如每 500 字切一段。这在代码文档场景下是灾难性的,因为一个方法的参数说明可能刚好被切到两段里,检索的时候只能捞到一半。
我的做法是按语义单元切片。一个 API 端点的完整说明是一个切片,一个配置项的完整说明是一个切片,一个代码示例是一个切片。切片的边界由文档的结构决定,而不是由字数决定。具体来说,我会用标题层级作为主要边界:每个三级标题下的内容作为一个切片,如果内容太长再按四级标题细分。
元数据设计是第二个关键点。每个切片除了内容本身,还需要携带足够的元数据,让检索时能够精确过滤。我一般会包含这些字段:
| 元数据字段 | 说明 | 用途 |
|---|---|---|
| source_url | 原始文档地址 | 溯源和引用 |
| page_title | 页面标题 | 展示和排序 |
| section_path | 章节路径(如"API 参考 > 对象存储 > 上传文件") | 上下文理解 |
| api_name | 涉及的 API 或方法名 | 精确匹配 |
| version | 适用的版本号 | 版本过滤 |
| content_type | 内容类型(概念说明/API 参考/代码示例/教程) | 结果分类 |
| last_updated | 最后更新时间 | 时效性排序 |
有了这些元数据,检索时就可以做很精细的过滤。比如模型问"v3 版本的上传方法",检索时就可以加上version=v3和api_name=upload的过滤条件,而不是在所有版本的所有方法里做语义搜索。
代码块的处理需要特别小心。代码块在 Markdown 里是用 ``` 包裹的,切片的时候要确保代码块不被切断。我见过太多系统把一段完整的代码示例切成两半,模型拿到半截代码,生成出来的东西自然也是半截的。
3.2 检索策略:混合检索才是正解
纯向量检索在代码文档场景下是不够用的。原因前面说过,代码问题往往需要精确匹配,而向量检索擅长的是语义相似。
我的做法是混合检索:向量检索 + 关键词检索 + 结构化过滤,三路并行,然后融合排序。
向量检索负责"语义相关"。比如用户问"怎么把文件传到云上",向量检索能找到"对象存储上传"相关的文档,即使文档里没有出现"传到云上"这几个字。
关键词检索负责"精确匹配"。比如用户问"upload 方法的 options 参数",关键词检索能精确找到包含 "upload" 和 "options" 的文档片段。这里我一般用 BM25 或者类似的算法,对代码标识符(类名、方法名、参数名)做特殊加权。
结构化过滤负责"条件筛选"。比如用户指定了版本号、指定了某个云服务、指定了某种编程语言,这些条件直接作为过滤条件应用到检索结果上。
三路结果融合的时候,我用的是加权倒数排名融合(Weighted Reciprocal Rank Fusion)。简单说就是每个结果在三路检索里各有一个排名,根据排名的倒数加权求和,得到最终分数。权重可以根据场景调整,代码场景下我一般把关键词检索的权重调高一些。
# 混合检索的融合排序示意 def hybrid_search(query, filters, top_k=10): # 三路检索 vector_results = vector_search(query, filters, top_k=50) keyword_results = keyword_search(query, filters, top_k=50) structured_results = structured_filter(filters, top_k=50) # 加权倒数排名融合 scores = {} for rank, doc in enumerate(vector_results): scores[doc.id] = scores.get(doc.id, 0) + 0.3 / (rank + 60) for rank, doc in enumerate(keyword_results): scores[doc.id] = scores.get(doc.id, 0) + 0.5 / (rank + 60) for rank, doc in enumerate(structured_results): scores[doc.id] = scores.get(doc.id, 0) + 0.2 / (rank + 60) # 按分数排序返回 return sorted(scores.items(), key=lambda x: -x[1])[:top_k]这里的 60 是平滑常数,防止排名靠前的结果分数过高。0.3、0.5、0.2 是权重,可以根据实际效果调整。我实测下来,关键词检索权重高一些对代码场景更友好。
3.3 工具设计:让模型像开发者一样查文档
MCP 工具的设计,核心原则是让模型能够像人类开发者一样,一步步缩小范围,最终定位到精确信息。
人类开发者查文档的流程通常是这样的:先搜索一个大概的关键词,看看有哪些相关页面;点进最相关的页面,浏览一下结构;找到具体的章节,仔细阅读;如果涉及代码,复制示例代码,根据实际情况修改。
MCP 工具应该支持这个流程的每一步。我一般会设计这几个工具:
search_docs:输入查询词和可选的过滤条件,返回相关页面列表(包含标题、摘要、URL)。这是入口工具,模型用它来发现有哪些相关文档。
get_page:输入页面 ID,返回该页面的完整内容(Markdown 格式)。模型用它来阅读具体页面的详细内容。
list_apis:输入服务名和可选的版本号,返回该服务下所有 API 的列表(包含名称、简要说明)。模型用它来了解某个服务提供了哪些能力。
get_api_detail:输入 API 名称和版本号,返回该 API 的完整说明(包含参数列表、返回值、示例代码、错误码)。这是最核心的工具,模型用它来获取生成代码所需的精确信息。
check_version:输入 API 名称和版本号,返回该 API 在该版本中是否可用、是否有变更。模型用它来避免使用已废弃的 API。
这五个工具的组合,基本覆盖了代码生成场景下的所有文档查询需求。工具的数量不宜过多,太多会让模型在选择时犹豫;也不宜过少,太少会导致模型无法获取足够的信息。
提示:工具的描述(description)非常重要。模型是根据工具描述来决定调用哪个工具的。描述要写得清晰、具体,说明这个工具适合什么场景、输入参数是什么格式、返回什么内容。我见过很多 MCP 实现效果不好,问题就出在工具描述写得太模糊。
3.4 版本管理:代码场景下的生命线
代码场景和普通问答场景最大的区别,就是版本。同一个 API 在不同版本里可能完全不同,而模型如果搞混了版本,生成的代码就是废的。
版本管理要在三个层面做。
第一个层面是文档层面。每个文档切片都要标注它适用的版本。如果某个 API 在 v2 和 v3 里都有,那就存两份切片,分别标注 v2 和 v3。检索时根据用户指定的版本过滤。
第二个层面是工具层面。工具的参数里要包含版本号,模型在调用工具时可以指定版本。如果模型没有指定,工具应该返回一个默认版本(通常是当前稳定版)的结果,并在返回内容里明确标注版本号,让模型知道它拿到的是哪个版本的信息。
第三个层面是提示层面。在系统提示里要明确告诉模型:"在生成代码之前,先确认用户使用的版本。如果不确定,先询问用户,或者使用 check_version 工具查询。"这个提示能显著减少版本混淆的问题。
我踩过的一个坑是:早期版本的系统里,我没有在工具返回内容里标注版本号,结果模型有时候会把 v2 的信息当成 v3 的用。后来我在每个工具返回结果的开头都加了一行[Version: v3],这个问题就基本消失了。模型对显式的版本标注非常敏感。
4. 三大云厂商知识检索架构的思路差异
4.1 思路一:文档即服务,工具化封装
第一种思路,我称之为"文档即服务"。核心思想是把官方文档包装成一组标准化的工具,通过 MCP 协议暴露给模型。模型不直接接触文档内容,而是通过工具调用来获取信息。
这种思路的代表是 Google 官方文档的 MCP 实现。它的特点是:
工具粒度细。不是简单地提供一个"搜索文档"的工具,而是针对不同类型的文档提供不同的工具。比如 API 参考文档有专门的工具,教程文档有专门的工具,代码示例有专门的工具。这样模型可以根据问题类型选择最合适的工具。
返回结构化数据。工具返回的不是一段文本,而是结构化的 JSON 数据。比如查询一个 API 的详情,返回的是包含参数列表、返回值类型、示例代码、错误码的结构化对象。模型可以直接从这个结构里提取需要的信息,而不需要从自然语言描述里"猜"。
强调版本和时效。每个工具调用都可以指定版本,返回结果里也明确标注版本和最后更新时间。这让模型能够做出基于版本的决策。
这种思路的优点是精确、可控、可追溯。缺点是工具的设计和维护成本比较高,需要针对每种文档类型单独设计工具。
4.2 思路二:统一检索层,语义优先
第二种思路,我称之为"统一检索层"。核心思想是建立一个统一的文档检索服务,把所有文档都索引进去,通过一个统一的检索接口对外提供服务。模型通过这个接口来查询文档,接口内部负责处理各种检索策略。
这种思路在一些云厂商的实现里比较常见。它的特点是:
统一的检索入口。不管是什么类型的文档,都通过同一个检索接口查询。接口内部会根据查询内容自动选择检索策略(向量检索、关键词检索、混合检索)。
语义理解优先。强调对查询意图的理解。比如用户问"怎么创建一个虚拟机",系统会理解到这是"创建计算实例"的意图,然后去检索相关的文档,而不是简单地匹配"虚拟机"这个词。
结果聚合。检索结果不是单个文档片段,而是聚合后的答案。系统会把多个相关片段整合成一个连贯的回答,附上引用来源。
这种思路的优点是使用简单、覆盖面广。缺点是精确性不如第一种思路,特别是在需要精确参数信息的代码场景下,聚合后的答案可能会丢失细节。
4.3 思路三:知识图谱,关系驱动
第三种思路,我称之为"知识图谱"。核心思想是把文档里的知识抽取成实体和关系,构建一个知识图谱,然后基于图谱来做检索和推理。
这种思路在一些厂商的内部实现里能看到影子。它的特点是:
实体抽取。从文档里抽取出 API、参数、类、方法、配置项等实体,以及它们之间的关系(如"方法 A 属于类 B"、"参数 C 是方法 D 的必填参数")。
关系推理。基于图谱做推理。比如用户问"怎么上传文件",系统可以从图谱里找到"上传文件"这个操作涉及的所有实体和关系,然后生成一个完整的操作路径。
动态更新。文档更新时,只需要更新图谱里对应的实体和关系,不需要重新索引整个文档库。
这种思路的优点是推理能力强、能够回答复杂问题。缺点是构建和维护知识图谱的成本很高,而且抽取的准确性直接影响效果。
4.4 三种思路的对比与选型建议
| 维度 | 文档即服务 | 统一检索层 | 知识图谱 |
|---|---|---|---|
| 精确性 | 高 | 中 | 高 |
| 覆盖面 | 中 | 高 | 中 |
| 实现成本 | 中 | 低 | 高 |
| 维护成本 | 中 | 低 | 高 |
| 适合场景 | API 参考、代码生成 | 通用问答、教程检索 | 复杂推理、跨文档问题 |
| 版本管理 | 强 | 中 | 强 |
我的选型建议是:如果你的核心场景是代码生成,优先考虑"文档即服务"思路。因为代码生成对精确性的要求最高,而"文档即服务"通过工具化封装,能够提供最精确的信息。如果预算和人力允许,可以在"文档即服务"的基础上,叠加一层"统一检索层"作为补充,用于处理教程类、概念类的查询。
"知识图谱"思路适合大型团队,有专门的团队来维护图谱。对于大多数团队来说,投入产出比不高。
5. 实操:从零搭建一个文档检索 MCP 服务
5.1 环境准备与依赖安装
我以 Python 技术栈为例,讲一下怎么从零搭建一个文档检索 MCP 服务。这个服务的目标是:把某个云服务的官方文档索引起来,通过 MCP 协议暴露给 AI 编程助手,让助手在生成代码时能够查询精确的文档信息。
先装依赖:
pip install mcp httpx beautifulsoup4 markdownify chromadb sentence-transformers rank-bm25这里解释一下每个依赖的作用:
mcp:MCP 协议的 Python SDK,用来实现 MCP 服务端。httpx:异步 HTTP 客户端,用来抓取文档页面。beautifulsoup4:HTML 解析库,用来提取正文内容。markdownify:把 HTML 转换成 Markdown,保留代码块和表格结构。chromadb:向量数据库,用来存储和检索文档向量。sentence-transformers:生成文本向量。rank-bm25:BM25 关键词检索。
注意:向量模型的选择很关键。代码文档场景下,我推荐用专门针对代码训练的向量模型,而不是通用的文本向量模型。通用模型对代码标识符的语义理解往往不够好。
5.2 文档抓取与预处理
第一步是把文档抓下来,提取正文,转换成 Markdown。
import httpx from bs4 import BeautifulSoup from markdownify import markdownify async def fetch_and_clean(url: str) -> str: """抓取文档页面并提取正文 Markdown""" async with httpx.AsyncClient() as client: resp = await client.get(url, timeout=30) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") # 移除噪音元素 for tag in soup.select("nav, footer, aside, .sidebar, .ad, .related-links"): tag.decompose() # 定位正文容器(不同文档站点的选择器不同,需要根据实际情况调整) main = soup.select_one("main, article, .content, #content") if not main: main = soup.body # 转换成 Markdown md = markdownify(str(main), heading_style="ATX") return md这里的关键是正文容器的选择。不同文档站点的 HTML 结构不一样,需要针对性地调整选择器。我的经验是,先手动打开几个页面,用浏览器的开发者工具看一下正文容器的 class 或 id,然后写对应的选择器。
如果文档站点有 sitemap 或者导航结构,可以先把所有页面 URL 抓下来,然后批量处理。注意控制抓取频率,不要给文档站点造成压力。
5.3 语义切片与元数据标注
拿到 Markdown 之后,下一步是切片。我前面说过,要按语义单元切,不要按固定字数切。
import re from dataclasses import dataclass @dataclass class Chunk: content: str source_url: str page_title: str section_path: str api_name: str version: str content_type: str def split_by_sections(md: str, url: str, title: str, version: str) -> list[Chunk]: """按标题层级切片""" chunks = [] # 按三级标题切分 sections = re.split(r"\n(?=### )", md) for section in sections: if not section.strip(): continue # 提取章节标题 lines = section.strip().split("\n") heading = lines[0].lstrip("# ").strip() if lines[0].startswith("#") else title # 提取 API 名称(从标题或内容里找) api_match = re.search(r"`(\w+)`|(\w+)\s*方法|(\w+)\s*API", section) api_name = api_match.group(1) or api_match.group(2) or api_match.group(3) if api_match else "" # 判断内容类型 if "```" in section: content_type = "code_example" elif "参数" in section or "返回值" in section: content_type = "api_reference" else: content_type = "concept" chunks.append(Chunk( content=section.strip(), source_url=url, page_title=title, section_path=f"{title} > {heading}", api_name=api_name or "", version=version, content_type=content_type, )) return chunks这段代码是示意性的,实际使用时要根据文档的具体结构来调整。核心思路是:用标题作为主要切分点,用内容特征来标注元数据。
5.4 混合检索索引的构建
切片完成后,需要建立检索索引。我一般建两个索引:向量索引和关键词索引。
import chromadb from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer # 初始化 chroma_client = chromadb.PersistentClient(path="./doc_index") collection = chroma_client.get_or_create_collection("docs") embed_model = SentenceTransformer("your-code-embedding-model") def build_index(chunks: list[Chunk]): """构建向量索引和关键词索引""" # 向量索引 texts = [c.content for c in chunks] embeddings = embed_model.encode(texts, show_progress_bar=True) collection.add( ids=[f"chunk_{i}" for i in range(len(chunks))], embeddings=embeddings.tolist(), documents=texts, metadatas=[{ "source_url": c.source_url, "page_title": c.page_title, "section_path": c.section_path, "api_name": c.api_name, "version": c.version, "content_type": c.content_type, } for c in chunks], ) # 关键词索引 tokenized = [tokenize(c.content) for c in chunks] bm25 = BM25Okapi(tokenized) return bm25 def tokenize(text: str) -> list[str]: """针对代码文档的分词""" # 保留代码标识符的完整性 tokens = re.findall(r"[a-zA-Z_]\w*|[0-9]+|[^\s\w]", text) return [t.lower() for t in tokens]分词这里要特别注意。通用的中文分词器会把upload_file切成upload和file,这在代码场景下可能丢失信息。我的做法是用正则表达式提取标识符,保留upload_file作为一个完整的 token,同时也把它拆开作为两个 token 加入索引。这样既能精确匹配,也能部分匹配。
5.5 MCP 工具的实现与注册
索引建好之后,就可以实现 MCP 工具了。
from mcp.server import Server from mcp.types import Tool, TextContent server = Server("doc-search") @server.list_tools() async def list_tools(): return [ Tool( name="search_docs", description="搜索文档。输入查询词,返回相关文档片段列表。适合在不确定具体 API 名称时使用。", inputSchema={ "type": "object", "properties": { "query": {"type": "string", "description": "查询词"}, "version": {"type": "string", "description": "文档版本,如 v3"}, "content_type": {"type": "string", "description": "内容类型:api_reference/code_example/concept"}, }, "required": ["query"], }, ), Tool( name="get_api_detail", description="获取指定 API 的详细说明,包括参数列表、返回值、示例代码。适合在已知 API 名称时使用。", inputSchema={ "type": "object", "properties": { "api_name": {"type": "string", "description": "API 或方法名称"}, "version": {"type": "string", "description": "版本号"}, }, "required": ["api_name"], }, ), ] @server.call_tool() async def call_tool(name: str, arguments: dict): if name == "search_docs": results = hybrid_search( query=arguments["query"], version=arguments.get("version"), content_type=arguments.get("content_type"), ) return [TextContent(type="text", text=format_results(results))] elif name == "get_api_detail": results = get_api_by_name( api_name=arguments["api_name"], version=arguments.get("version"), ) return [TextContent(type="text", text=format_api_detail(results))]工具描述我写得比较详细,明确说明了每个工具适合什么场景。这是有意为之的,因为模型是根据描述来选择工具的。描述写得越清楚,模型选错工具的概率就越低。
5.6 与 AI 编程助手的集成测试
MCP 服务写好后,需要配置到 AI 编程助手里。不同的助手配置方式不一样,但基本都是在配置文件里加上 MCP 服务的启动命令。
配置好之后,我一般会做几组测试来验证效果:
测试一:精确 API 查询。问助手"用某个 SDK 上传文件的方法签名是什么",看它是否能通过工具查到精确的参数列表。
测试二:版本区分。问助手"v2 和 v3 的上传方法有什么区别",看它是否能分别查询两个版本并对比。
测试三:代码生成。让助手生成一段使用某个 API 的代码,然后检查生成的代码是否与文档一致。
测试四:错误处理。问助手"这个 API 返回某个错误码时应该怎么处理",看它是否能查到错误码的说明。
这几组测试基本能覆盖核心场景。如果测试中发现模型没有调用工具,或者调用了错误的工具,就需要回头调整工具描述或者系统提示。
6. 常见问题与排查技巧实录
6.1 模型不调用工具,直接凭记忆回答
这是最常见的问题。模型明明有工具可用,但它就是不用,直接凭训练时的记忆给你生成代码。
原因通常有三个:一是工具描述写得不够清楚,模型不知道什么时候该用;二是系统提示里没有强调要使用工具;三是模型本身的能力限制,某些模型对工具调用的支持不够好。
解决办法:首先,在系统提示里明确写"在回答任何与 API 使用相关的问题之前,必须先通过工具查询文档"。其次,把工具描述写得更加具体,最好包含使用示例。最后,如果模型本身对工具调用支持不好,考虑换一个对工具调用支持更好的模型。
我实测下来,系统提示里加一句"不要凭记忆回答 API 相关问题,必须先查询文档",能显著提升工具调用率。
6.2 检索结果不相关,模型查了等于没查
模型调用了工具,但返回的结果和问题不相关。这通常是检索环节的问题。
排查思路:先看查询词是什么。如果模型传的查询词太宽泛(比如只传了"上传"两个字),检索结果自然不精确。这时候需要优化工具描述,引导模型传更具体的查询词。
如果查询词没问题,那就是检索算法的问题。检查向量模型是否适合代码场景,检查关键词检索的分词是否正确,检查元数据过滤是否生效。
我遇到过一个典型问题:文档里 API 名称是upload_object,但用户习惯说"上传文件"。向量检索能找到相关文档,但关键词检索找不到,因为"上传文件"和"upload_object"没有字面重叠。解决办法是在文档预处理阶段,为每个 API 生成同义词标注,把"上传文件"、"上传对象"这些常见说法也加入关键词索引。
6.3 版本混淆,生成的代码用错版本
模型查到了文档,但用错了版本。比如用户用的是 v2,模型却按 v3 的文档生成了代码。
这个问题要从三个层面解决。第一,在系统提示里要求模型先确认版本。第二,在工具返回结果里显式标注版本号。第三,如果用户没有指定版本,工具返回默认版本的结果时,要在结果开头明确写"以下内容基于 v3 版本,如果你使用的是其他版本,请指定版本号重新查询"。
我还会在工具里加一个check_version功能,让模型可以主动查询某个 API 在哪些版本里可用。这样模型在不确定的时候,可以先去查一下版本信息。
6.4 工具调用延迟高,影响体验
每次工具调用都是一次网络请求,如果检索服务响应慢,整体体验就会很差。
优化方向有几个。第一,把向量检索和关键词检索做成并行,而不是串行。第二,对常见查询做缓存,比如某个 API 的详情查询,如果短时间内多次查询同一个 API,直接返回缓存结果。第三,优化向量索引,使用更快的近似最近邻算法。第四,控制返回结果的数量,不要一次返回几十个片段,只返回最相关的三到五个。
我实测下来,把返回结果从 10 个降到 5 个,延迟能降低 30% 左右,而效果几乎没有下降。因为模型真正用到的往往就是最相关的那两三个片段。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型不调用工具 | 工具描述不清/系统提示未强调 | 检查工具描述和系统提示 | 优化描述,加显式提示 |
| 检索结果不相关 | 查询词太宽泛/检索算法不适配 | 查看模型传入的查询词 | 优化工具描述,调整检索策略 |
| 版本混淆 | 未标注版本/未确认版本 | 检查工具返回内容 | 显式标注版本,加版本确认提示 |
| 延迟高 | 串行检索/结果过多 | 测量各环节耗时 | 并行检索,加缓存,减少返回数量 |
| 代码块被切断 | 切片策略不当 | 检查切片边界 | 按语义单元切片,保护代码块 |
| 工具调用失败 | 参数格式错误/服务异常 | 查看工具调用日志 | 加参数校验,加错误处理 |
6.6 几个独家避坑技巧
技巧一:在工具返回结果里加"使用提示"。比如返回 API 详情时,在末尾加一句"注意:此方法在 v3 中已废弃,推荐使用 xxx 方法"。这个提示能帮助模型做出更好的决策。
技巧二:为常见问题预置"快捷查询"。比如"如何上传文件"、"如何创建实例"这些高频问题,预先配置好查询路径,模型调用时直接返回预置结果,不走完整检索流程。这能显著降低延迟。
技巧三:定期分析工具调用日志。看看模型最常调用哪些工具、传什么查询词、哪些查询返回了空结果。这些数据是优化系统的金矿。我通过分析日志发现,模型经常把 API 名称拼错,于是在检索时加了模糊匹配,效果提升明显。
技巧四:不要追求一次查询就返回完美结果。让模型多查几次,每次查一个具体的点,比一次返回一大堆信息效果更好。因为模型在多轮查询中能够根据上一轮的结果调整下一轮的查询,逐步逼近精确答案。
7. 我在这件事上的几点真实体会
搭这套东西的过程中,我最大的体会是:文档检索系统的效果,八成取决于文档预处理的质量,两成取决于检索算法。很多人把精力花在调检索算法上,却忽略了文档切片和元数据标注这些基础工作。实际上,如果切片切得好、元数据标得准,哪怕用最简单的检索算法,效果也不会差。
另一个体会是:不要试图让模型一次就拿到所有信息。我早期设计的工具,总想一次返回尽可能多的内容,结果模型被大量无关信息干扰,反而抓不住重点。后来我把工具设计成"小步快跑"的模式,每次只返回最相关的少量信息,让模型自己决定下一步查什么,效果反而更好。
还有一个很实际的体会:版本管理是代码场景的生命线。我在版本管理上踩过的坑,比在其他所有环节加起来都多。如果你的文档检索系统要服务代码生成场景,一定要在版本管理上投入足够的精力。每个切片标版本、每个工具支持版本参数、每次返回标注版本号,这三件事一个都不能少。
最后说一个我觉得很有用的实践:把工具调用日志和最终生成的代码关联起来分析。当生成的代码有问题时,回溯去看模型调用了哪些工具、拿到了什么信息、在哪一步理解错了。这个分析过程能帮你发现系统的薄弱环节,比盲目优化有效得多。我通过这种方式发现了好几个隐藏的问题,比如某个 API 的文档页面结构特殊,切片时把参数说明切到了两个片段里,导致模型只拿到了一半参数信息。
这套东西还在持续迭代中,文档在更新,模型在升级,检索策略也在调整。但核心思路是不变的:把权威文档变成模型可以实时查询的外部知识源,让它在生成代码之前先查证,而不是凭记忆瞎猜。这个思路一旦落地,幻觉代码的问题就能得到根本性的改善。