要是两年前有人让我从一个空白目录去搭一套 AI 工程系统,我大概率会觉得他在开玩笑。最近大半年我几乎每天都在跟 ai-engineering 这几个字打交道,真正难的不是模型怎么选,而是怎么把检索、生成、评测、迭代这一整条链路老老实实搭出来。这篇文章来自我当时从零开始做的一个文档问答项目,里面会包含完整思路、关键步骤、参数调优,以及后来线上踩到的各种坑。适合两类人看:一类是从没写过 AI 应用的工程师,另一类是已经能调通接口、但总觉得项目很脆的人。
1. 开始前先分清“调通接口”和“做AI工程”之间的差距
很多人以为 AI 工程就是从网上抄一段调用大模型的代码,把 prompt 写得稍微精致一点,然后项目就“成了”。我一开始也这么想,但实际上跑起来以后才发现,真正的工作量集中在数据怎么准备、上下文怎么组织、答案怎么验证、系统怎么恢复。调通一个接口只需要十几分钟,把它变成一套可以长期维护的 AI 工程,才是从零开始真正的意义。
1.1 我选的切入项目:文档问答
我选的第一个实战项目是一个内部文档问答系统,场景很简单:有一堆产品手册、操作说明和项目复盘文档,员工用自然语言提问,系统要返回有依据的答案,并且标明信息来自哪一份文档。选这个场景是因为它足够典型,又有清晰的评估标准,正好把 ai-engineering 里的核心环节都覆盖到:文档解析、文本切分、向量化、召回、生成、引用校验。
文档问答看起来不起眼,但它其实是很多 AI 应用的骨架。你换成企业知识库、客服机器人、工单辅助系统,玩法都一样。先把这个最小闭环跑通,后面再怎么扩展心里都有底。我见过不少团队第一版就想上个“大而全”的智能助手,结果需求模糊、数据没整理,折腾三个月还是只能 demo。我更推荐从小而稳的文档问答开始,因为它的每一步都能被量化验证。
1.2 初版方案里我故意没做的事
从零开始不等于什么都要自己造轮子,也不等于第一版就要上最重的方案。我在初版里故意没做三件事:第一,不做微调;第二,不自己部署开源大模型;第三,不接超过两个以上的外部依赖。
先说微调。我当时手里能用来训练的数据只有几百份文档,对于让模型具备问答能力这件事没有任何帮助。微调擅长改变模型的风格、格式和特定领域表达,但知识本身还是需要从上下文里给到它。数据量不够时,微调反而会让模型产生严重遗忘。所以第一版我选择“检索增强生成”,也就是把相关资料段先找出来,塞给模型作为上下文,再让它基于上下文作答。
再说自部署模型。本地部署的好处是数据不出内网,但 GPU 资源、并发调度、显存溢出、推理加速这些运维成本会立刻扑上来。初期为了验证业务流程,我直接使用托管 API,把注意力集中在整条链路的正确性上。等后面的确需要私有部署时,只要模型层接口设计得合理,替换成本并不高。
最后的结论是:初版等于“托管大模型 API + 文档切分 + 向量检索 + 上下文组装 + 流式输出”。这套组合足够简单,又能撑住一个真实的业务场景。
1.3 先把评测集建出来,再开始写代码
这是我这次项目里最值的决定。在写任何代码之前,我花了整整一个下午,从产品文档里挑了 30 个真实问题,分成四类:可以直接从文档里找到答案的、需要跨多份文档归纳的、文档里完全没有的、答案藏在表格或代码片段里的。
为什么这么干?因为没有评测集的 AI 工程就是撞大运。大模型输出的随机性本来就比传统软件高很多,你改了一段检索逻辑,可能这次回答变好了,下次又变差了。只有把同样的 30 个问题反复跑,才能量化每一次改动是变好还是变坏。
我给自己定的三个硬标准:第一,回答正确的比例要达 80% 以上;第二,超过半数的答案必须能标注出正确来源;第三,对文档里不存在的问题,模型要学会承认不知道,而不是强行编一个答案。这三条标准后来成了我判断每个版本能不能上线的底线。
2. 搭建整个AI工程前,先把这四个环节想清楚
在写主流程之前,我先把整个架构在脑子里拆成了四个环节:模型访问、知识库与检索、生成编排、可观测性。任何一个环节偷懒,后期都会用事故加倍还回来。
2.1 模型访问:给自己留一个“可换模型”的口子
我一开始直接用官方 SDK 调大模型,写起来确实爽,但后来发现一个问题:想换模型、想设置不同的超参数、想统一统计调用次数和费用,散落在各处的代码根本改不动。于是我在代码里加了一层非常薄的模型客户端接口,对外只暴露一个chat()方法,内部再去决定调用哪个供应商、哪个模型、什么温度。
class LLMClient: def __init__(self, provider: str = "default"): self.provider = provider def chat(self, messages, temperature=0.2, stream=False): if self.provider == "default": return self._call_openai_compatible_api(messages, temperature, stream) # 后续接其他 provider 时,只需要在下面继续扩展 raise NotImplementedError别小看这一层。它最大的价值不是抽象,而是让你在模型接口出问题、或者出现更合适的模型时,可以快速切换而不需要改动业务代码。哪怕你现在只有一个供应商,也建议你留一个这样的口子。工程上有一条朴素的道理:没有哪家大模型供应商会永远满足你的全部需求,有备选路,比什么都强。
2.2 知识库与检索:向量不是唯一解
刚开始我理所当然地以为,要做知识库就得上向量数据库,把文档全都切成小块,然后用 embedding 算相似度。这个思路没错,但有个容易被忽略的问题:向量检索在模糊语义匹配上确实好,可它不一定能精准命中“版本号”“产品型号”这类精确关键词。比如用户问“V3.2 版本的参数”,向量检索很可能把 V3.2 和 V4.0 的内容混淆。
我最终做的是“向量检索为主、关键词检索兜底”的双路召回。向量负责语义相近但不一定含相同字面的内容,关键词负责精确匹配编号、型号、报错码这些实体信息。两边拿回来以后做合并去重,再按分数排序。这个改动让评测集里 6 个涉及精确型号的问题从 50% 的正确率提升到了 100%。
另一个让我踩坑的点是文档切分。切得太碎,上下文不够完整;切得太大,又会把很多无关内容混进来,直接拉低生成质量。我在测试后常用的一组参数是chunk_size=512、overlap=64,按字符数切分,同时尽量让切分发生在段落边界而不是句子中间。这个参数不是固定的,需要根据文档类型去调整,但初期从这两档开始试是稳妥的。
2.3 生成编排:让模型在约束下工作
很多人以为 prompt 写得越复杂越好,但其实不是。模型需要的是“清晰的边界”,不是长篇大论的表演。我的系统 prompt 里写死了三条约束:只依据给定上下文回答;如果上下文不足以回答,就明确说不知道;每个关键结论后面必须标出来源编号。
你是文档问答助手。你会收到若干条参考资料,每条以 [序号] 开头。 请严格按照参考资料作答: 1. 若资料中没有相关信息,请直接回答“资料中未找到相关内容”。 2. 不要使用参考资料以外的知识进行补全。 3. 在句末标注来源,例如 [1][3]。生成时还要控制采样参数。我用的是temperature=0.1,接近确定的输出。如果模型支持seed,我也会固定下来。既然做的是文档问答,用户要的是稳定、准确,不是文采。把随机性留到写作类、创意类场景去释放更合适。
2.4 可观测性:没有日志的AI项目像盲人开车
传统后端日志记录的是请求参数和响应状态,AI 工程还要多记录几样东西:最终发给模型的完整 prompt、模型返回的原稿、召回出来的文档片段、每段来源对应的分数、本次调用消耗了多少 token、耗时多少。
我第一版没有记录完整 prompt,后来遇到一次线上回答错误,想复盘时连模型当时看到了什么都没法查,只能靠用户截图猜。后来我老老实实把每个请求的输入输出全部落库,尤其是那些回答“错误”的样本。对比记录后能非常清楚地看到,问题到底是出在召回到了错误资料,还是模型自己把资料解读错了。没有这层日志,所谓的调优就是盲人摸象。
3. 从零跑通最小闭环:文档问答的实操过程
这部分我尽量写成可以直接照着操作的步骤。整个流程很短,但每一步都有细节,我会在关键位置停下来解释为什么这么做。
3.1 环境准备与依赖选择
这次项目我的技术栈很克制:Python 3.11、一个兼容 OpenAI 协议的大模型 API、一个 embedding API、Chroma 做本地向量存储、没有接 LangChain。
很多人问我不接 LangChain 怎么行?我倒觉得,从零开始做 ai-engineering,初期最好亲手写流程。LangChain 提供的是抽象和便利,但当你想精确控制上下文格式时,框架的封装反而会变成一层迷雾。我先把每一步用最简单的代码跑通,后面如果出现重复劳动,再挑值得接的框架局部引入,成本比自己先钻进框架低得多。
pip install chromadb openai pypdf python-docx tiktokenpypdf负责读取 PDF 文本,python-docx负责处理 Word 文档,tiktoken用来统计 token 数量,避免上下文超过模型限制。这样一套环境就够了。
3.2 文档解析与索引构建
文档解析是整个项目里最不起眼、但最容易出问题的一步。PDF 里如果全是扫描图片,直接抽文本是抽不出来的,需要 OCR;Word 里的表格如果直接转文本,行列关系很容易丢失。我的做法是:能转 Markdown 就先转 Markdown,保留标题层级和表格结构;解析出来以后先做一个抽样检查,把乱码、重复页、图片说明这类问题在索引前处理掉。
然后做切分。我用的函数逻辑大致如下:
def split_text(text: str, chunk_size: int = 512, overlap: int = 64) -> list[str]: paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks, current = [], "" for para in paragraphs: if len(current) + len(para) > chunk_size: chunks.append(current) current = para else: current += "\n\n" + para if current: chunks.append(current) return chunks注意这里保证切分点在段落之间。实际切完以后,我还会为每个 chunk 记录一个来源文件名和章节路径,方便最终答案里标注引用。索引建立起来后存在 Chroma 里,每条记录包括chunk 文本、向量、元数据。这一步完成后,链路里的“知识”部分就绪了。
3.3 查询改写的Prompt设计
用户提问经常是非常口语化的:“那个升级失败的问题怎么解决的?”如果不做改写,直接拿这句话去检索,效果通常很差。因为文档里大概率写的是“升级过程中遇到错误码 0x80070057”,而不是“升级失败”。这时我加了一步查询改写,先把用户问题变成一个更适合检索的查询语句。
你是一个检索查询改写器。把用户问题改写成适合在文档库中检索的查询语句。 要求: - 保留产品名、型号、错误码等关键实体。 - 不要添加原问题中不存在的信息。 - 改写结果只输出一段话。这一步看似多了一次模型调用,但它对整体质量的提升非常大。尤其是口语化提问频繁的场景,不改写的召回准确率会低到你怀疑人生。改写后的查询同时用于向量检索和关键词检索,能明显提高召回命中率。
3.4 上下文组装与生成验证
召回完以后,把所有结果按相关度排序,然后拼成模型输入。我在这里遇到过两个问题:一是上下文太长,超出模型窗口;二是不同来源的内容自相矛盾,导致模型不知道听谁的。解决办法是先加预算:最多取前 8 个 chunk,每个 chunk 最多 300 个 token,超出的部分截断。宁可信息少一点,也不要让模型淹没在大量噪声里。
组装上下文的代码类似这样:
def assemble_context(docs): parts = [] for idx, doc in enumerate(docs, start=1): content = doc.text[:300] parts.append(f"[{idx}] {content}") return "\n\n".join(parts) def run_query(user_question): rewritten = rewrite_query(user_question) hits = hybrid_search(rewritten, top_k=8) context = assemble_context(hits) prompt = build_prompt(context, user_question) answer = llm.chat(prompt, temperature=0.1) return answer, hits生成完以后,我还加了很便宜但很有效的校验:检查模型输出的来源编号是否真的落在实际给出的上下文编号范围内。这一步能拦住不少“引用幻觉”。如果模型写了一个 [9],但上下文只有 8 条,那基本能确定它在编造,这个请求会触发生成降级:重新生成,或者直接返回“检索到的资料不足”。
4. 跑通后先别急着上线:效果、成本与稳定性实测
demo 能跑通只是第一步。真正让我花掉一半项目时间的,是上线前的评测和调优。这里我把实测过程展开说一下,包括我改了哪些参数、效果差多少、成本涨了多少。
4.1 用评测集做一轮暴力检测
第一版整体跑通后,我直接把 30 个评测问题全部执行了一遍。结果有点惨:正确率 63%,来源标注不全,还有两个不在资料里的事实性问题,模型也煞有介事地编了答案。
问题很清楚:第一版里普通问题表现尚可,跨文档归纳和精确型号查询是重灾区。于是我开始针对失败样本做分析,而不是凭感觉调参。我拿失败样本去翻可观测性日志,看召回命中了哪几段,再看模型最终回答时忽略哪段的关键信息。很快定位到第一波问题:跨文档问答时,单个 chunk 内容不完整,模型明明从两个段落各学了一半,却因为段落被切碎,没有机会看到完整上下文。
4.2 检索质量的两个调参方向
针对跨文档归纳弱的问题,我做了两个方向的调整。第一个方向是把相关度阈值调高。最开始top_k=8,很多低分噪声混了进来,模型容易被无关段落干扰。我改成先取top_k=20,再按分数阈值截断,只保留相似度大于 0.25 的段落。表面上是多取了候选,实际上把低质量段排除得更果断。
第二个方向是加入“段落级去重”。文档里经常有重复内容,比如同一份说明在几个章节里反复出现。如果不做相似度去重,检索结果可能前 5 条全是同一件事的复述,真正需要的信息反而排到后面。我在召回后加了一个简单操作:对已选片段里相似度太高的文本只保留一条,强制提升结果多样性。
改完以后,正确率从 63% 涨到了 83%,来源标注率到了 60%。虽然还没完全达标,但方向已经清晰了。后面我又针对少量失败样本,把问题改写加上错误码保真,精确型号类问题的成功率才真正稳定下来。
4.3 成本与延迟的实测数据
我顺手记录了一组真实数据:一次标准问答,大约消耗输入 token 1500、输出 token 200。30 次评测大约消耗 5 万 token,折合人民币几块钱。单次请求的端到端延迟大约 2.5 秒,其中 1.2 秒花在模型生成上,0.4 秒花在向量检索和排序上,剩下 0.9 秒是网络开销。
这个数据说明一个问题:文档问答这种场景,成本的大头不是模型输出,而是重复的检索改写和长上下文输入。我当时做了三个优化:给相同或者高度相似的问题加缓存,精确重复的问题直接复用答案;把静态的文档索引一次性预加载到内存;对多轮追问做一些裁剪,避免历史消息把上下文撑爆。这三板斧下去,单次请求成本下降了将近 40%。
4.4 流式输出与并发控制的取舍
交互体验上,流式输出是必须做的。用户等一个完整回答等 2.5 秒会显得很漫长,但流式输出能做到首 token 在 0.5 秒内到达,体感快非常多。代价是后端从简单的同步请求变成了流式转发,如果网关层不加超时控制,一个慢请求可能拖住整个连接。
我当时的做法是:对外输出启用流式,内部仍然保留非流式评测模式。因为评测集要统计完整答案,流式反而让日志处理和比对变复杂。另外并发方面,我按照供应商限流把并发数压到 10 以内,并加了信号量控制,避免用户一多就把配额打满。实测下来,10 并发以内响应时间和单请求几乎一致,超过之后开始明显变慢。这是托管 API 的通病,初版先接受这个限制,后面再考虑本地推理分流。
5. 我把这些坑挨个踩了一遍:常见问题排查手册
这部分是纯经验汇总。每个问题我都经历过,有些排查过程特别费时间,写出来帮你绕开。
5.1 模型答得离谱,但检索结果看起来没问题
这种情况十有八九是 prompt 约束不够强,或者模型被上下文中某些模棱两可的段落带偏了。我先检查系统 prompt 里有没有“只依据上下文回答”的明确指令;然后检查拼接上下文时是否把来源编号弄混了;最后把温度调低再试一次。如果问题还在,那就去日志里看模型到底接收了什么,很多时候答案错误是因为你的 prompt 里混进了不相关内容。
5.2 召回认为“找到了”,生成的答案却没用上
我遇到过最典型的情况:向量检索分数最高的段落,包含很多关键词,但不包含真正回答问题的核心句子。比如用户问“如何回滚”,召回的段落标题是“回滚方案”,但内容其实在讲升级流程。模型拿到了这段文字,发现里面没有答案,又不敢不回答,就开始编。
解决办法是提高召回多样性。我后来把top_k从 8 调成 20,去掉相似度太低的结果后,再额外保留 2 个关键词检索命中的段落,强制多样性。同时我加入了一步“相关度验证”:用一个小 prompt 让模型判断召回段落是否和问题有关。准确率不高,但能挡住明显不相关的噪声。
5.3 文档更新了,AI却还停留在旧版本
这是索引和源文档同步的问题。刚开始我把索引建完就丢在那里,等文档更新了,线上跑的还是旧数据。后来我在任何文档新增、删除、变更时,都会触发增量更新任务:先删除对应文档的所有旧 chunk,再重新解析并写入新 chunk。同时记录每个 chunk 的来源 md5,内容变化了才重写,避免无意义的全量重建。
5.4 接口超时和限流怎么根治
托管 API 不可能永远稳定。我遇到过的三类问题分别是:单次响应太长导致网关超时、并发过高触发限流、偶发网络抖动导致连接断开。通用解法是重试加指数退避,但要注意区分错误类型——如果是 429 限流,重试间隔要更长;如果是 500 内部错误,可以快速重试一两次。同时给所有模型调用加 30 秒硬超时,避免一个异常请求无限挂在那里消耗线程资源。
| 现象 | 常见原因 | 我采用的排查方式 |
|---|---|---|
| 答案无来源 | 上下文未带编号 | 检查组装上下文格式和 prompt 指令 |
| 来源编号越界 | 模型引用幻觉 | 校验输出编号是否在 1~N 范围内 |
| 答案覆盖旧版本 | 索引未随文档更新 | 增量更新并校验来源 md5 |
| 接口频繁超时 | 并发超限 / 响应过长 | 限流队列 + 30 秒硬超时 |
| 检索结果雷同 | 重复段落占比高 | 召回后做相似度去重 |
6. 让AI工程从“demo”变成“系统”的复盘
回顾整个项目,我从零开始把 ai-engineering 从概念落成了一套能用的系统,但更重要的收获是思维方式上的转变:传统工程面对的是确定性逻辑,AI 工程面对的是概率输出,所以一切设计都得围绕“降低不确定性”来展开。
6.1 把不确定性变成可度量的东西
模型回答永远有随机性,但一个合格的 AI 工程系统不应该让随机性失控。我现在做任何 AI 功能,都会先问三个问题:如果模型答错,用户能否感知到?失败之后有没有降级方案?这次失败能不能被日志和评测集捕获?只要有一个答案是否定的,就不会上线。把不确定性变成可度量的指标,是 AI 工程化最关键的一步。
我用了一个很土但有效的办法:所有生成接口都带有版本号。模型版本、prompt 版本、召回参数版本都会记录在响应头里。后续评估某次线上反馈时,我能直接知道这个答案是由哪个组合生成的,而不是在那里猜。这个做法不花一分钱,但能让每次调优都有据可查。
6.2 评估脚本是最好的护城河
后来同事问我,为什么项目迭代起来一直很稳?答案是我的评测脚本比大多数人想象得早很多。我把 30 个问题固定下来,每改一次代码、每换一次模型、每调一个参数,都整批跑一遍。虽然 30 个问题覆盖面还不够广,但能拦住 80% 的回归问题。现在我的目标是把评测集扩充到 200 个问题,并引入人工抽检。
如果你想走得更远,可以把评测集垂直到具体业务里。比如客服场景,就多放一些用户真实提问;文档问答场景,就多放跨章节归纳题;工具调用场景,就多放错误参数组合。评测集跟着业务走,比追求通用测评指标实用得多。
6.3 我的下一步和给你的建议
这个项目目前已经稳定运行,后续我优先会做两件事:第一,把高频问题沉淀成固定的 few-shot 示例,降低模型每次都要绞尽脑汁的程度;第二,开始评估小模型私有化部署的可行性,因为线上很多问题集中在私有知识和低延迟,一旦成本和性能算得过来,就要把这条路打通。
如果你也想从零开始做一个 ai-engineering 项目,我给你的建议是:不要先买一堆课,也不要先抄一堆框架。找一份你手头真实的文档,写一个最朴素的问答脚本,把数据切分、向量化、召回、生成、评测跑通,哪怕界面完全没有、代码很丑,也值得。跑通以后再回过头来改进每一步,你会对每个参数、每个设计决策的因果有非常直接的体感,这种体感是任何教程都给不了你的。
最后分享一个小技巧:在你项目目录里建一个eval/文件夹,每次改动后,先跑评测再写提交。这个习惯表面上让每次迭代慢了几分钟,实际上会帮你省掉无数个“怎么改挂了”的深夜。