资讯详情

资讯详情

AI应用后端从单体到微服务:模型服务、业务服务与知识库的拆分实践

上个月某开发同学拿着一套AI问答系统的代码来找我聊架构问题。项目本身不复杂一个前端页面一个FastAPI后端里面塞了模型推理、知识库检索、用户会话管理外加上传文档解析的逻辑。功能都通了就是改代码越来越痛苦。改一个SQL查询整个服务重启模型几十GB重新加载一次要好几分钟GPU被高并发用户请求打满结果内部测试接口也跟着卡死日志混在一起压根分不清一个报错是模型那边出的还是业务逻辑出的。这几乎是所有AI应用从Demo走向稳定服务的必经坎。这个问题的核心落到一句话上就是模型服务、业务服务与知识库这三类完全不同的计算形态被硬塞进了一个进程。这篇文章是我最近重构类似系统时的完整拆分思路把为什么拆、怎么定边界、接口怎么走、部署怎么配、线上怎么排坑都讲一遍。适合正在做AI应用后端、RAG落地、模型服务化或者想把单体项目拆成多服务的开发者参考。先给结论逻辑上必须拆物理上可以分步走。1. 内容整体设计与思路拆解先从单体为什么扛不住说起1.1 拆之前踩过的坑单体模式下的资源与故障耦合不拆的时候最直观的问题是资源和进程完全耦合。模型推理依赖GPU显存和CUDA上下文业务逻辑依赖CPU和内存知识库检索依赖磁盘IO和向量索引。三类负载放在同一个进程里互相挤兑。实测过一个项目模型加载完占掉近20GB显存业务服务一出现高并发CPU先飙到满载模型推理延迟直接翻倍反过来模型批量推理时把GPU打满正常的业务查询也跟着变慢。谁也别想好好干活。更要命的是发布和重启的问题。模型权重在进程内常驻整个应用只要改一行代码发布时就要重启进程。模型重新加载是分钟级的这段时间用户请求全部失败。于是团队慢慢形成一种默契业务代码尽量攒着改谁也不想触发一次模型重载。这个“默契”一旦形成迭代速度就是灾难。故障更是互相放大的。知识库向量库偶发慢查询所有接口跟着超时模型服务报错时业务侧拿到的响应和正常返回长一个样排查只能靠猜。问题根本不在于某个库写得好不好而在于架构上就没有给故障设置“隔离带”。所以拆分的第一诉求不是“架构师看着爽”而是四个实打实的目标资源隔离、独立扩缩容、独立发布、故障止损。这四个目标决定了拆分的基本形态模型服务、业务服务、知识库不能互为“内部函数”必须成为边界清晰的独立单元。1.2 拆分逻辑模型服务管计算业务服务管状态知识库管记忆怎么定边界是拆分里最关键的一步。我用的是一句话原则模型服务管计算业务服务管状态知识库管记忆。模型服务的边界最干净。它只接收结构化的输入输出结构化的结果自身无状态。客户端传什么它就根据模型能力返回什么不关心用户身份、不存会话、不查业务库。说得直白一点模型服务就是一个“AI计算器”输入prompt参数输出生成的文本或者向量。无状态给这个服务带来了两个巨大优势任意实例可以互相替代扩容就是多加几个副本模型推理出现问题影响范围被锁死在推理这一层不会把业务库、文件存储一起带崩。业务服务则是“有状态的编排者”。用户会话、权限校验、上下文拼接、计量计费、流程控制都归它管。它是唯一能直连业务数据库的服务。用户发来一个问题业务服务负责把历史对话和检索到的知识片段组装成一个上下文再把这个上下文作为输入调模型服务拿到结果之后回写会话记录。业务服务不加载模型、不处理原始文档只负责把模型的能力编排成产品功能。知识库是一条完整的数据链路核心内容是“离线入库”和“在线检索”两部分。离线入库要处理文档解析、清洗、切分、向量化、索引构建在线检索负责把用户的查询向量化和向量库里的片段做相似度召回可能还要接重排模型。知识库本身不应该属于“模型服务”因为它的核心是数据管道和索引系统不是推理引擎也不应该全塞进业务服务因为入库任务往往是重计算、长耗时、适合异步执行的。单独拉出来才方便控制和优化每一环。1.3 什么时候不该拆避免过度设计的判断标准拆分虽好但不是所有项目都该一上来就三套服务分开部署。我见过不少团队内部演示Demo单机部署并发个位数硬要搞三个服务加消息队列最后运维成本比业务成本还高。这种情况下拆分的收益根本覆盖不了复杂度。我的判断标准就一条改业务代码的时候要不要被迫重新加载模型进程如果需要就该拆如果项目还在Demo阶段改代码无所谓重启那逻辑上做好模块划分就够——模型推理代码独立成包业务代码不直接import模型管道代码通过一个内部接口调用将来物理拆分时只是把接口调用从本进程函数换成HTTP调用。这个“从逻辑拆分到物理拆分”的过渡思路实际做下来比一步到位稳得多。还有一个判断维度是团队规模。一个人维护全栈物理拆分三个服务意味着三份部署配置、三套日志、三套监控一个人很容易被拖垮。但哪怕是单人项目只要模型加载时间超过30秒就要认真考虑物理拆分了。启动一次等几分钟这时间谁也耗不起。2. 核心细节解析与实操要点三个服务各自的正确打开方式2.1 模型服务无状态推理的正确封装方式模型服务是整个架构里技术含量最高的部分也是最容易“想当然”的地方。很多人以为套个FastAPI把模型包一层就是模型服务了实际会遇到很多细节问题。第一层选择是推理框架。直接用原始模型框架写推理代码当然能跑但并发一上来GPU利用率会很难看。推荐直接用推理引擎像vLLM这类支持连续批处理和PagedAttention的框架能把单卡吞吐拉高一个量级。选型上不用纠结太多凡是生产级的模型服务优先考虑这些引擎业务代码和模型框架之间通过一行API调用隔离将来换模型、换框架都不影响上层。第二层是进程模型。模型加载成本极高动辄几十GB显存、数分钟加载时间所以模型权重必须在worker进程内常驻。这里有个新手非常容易踩的坑为了让FastAPI支持高并发把uvicorn的workers设置成4个。后果就是每个worker都会加载一份完整模型副本4份权重直接把显存撑爆。模型服务恰恰不能靠多worker提升并发正确做法是一个GPU实例跑一个worker配合异步框架和推理引擎自己的并发调度能力来承接请求要提升吞吐优先靠连续批处理和增加GPU实例数而不是在同一台机器上开多个并行进程。第三层是通信协议。推理任务天然适合长连接因为请求和响应都是高吞吐的。模型服务对外暴露的接口建议统一采用流式格式客户端发起请求服务端用流式响应逐段返回生成结果这样用户首字延迟低体验好。内部链路如果追求极致的吞吐可以考虑用gRPC替换HTTP但这对大多数中小型项目来说不是必须的HTTP/JSON足够稳定也方便排查问题。模型服务还有一个铁律不接任何业务数据库不主动调知识库。所有上下文在请求里带全。很多团队为了让“模型能回答得更准”让模型服务自行查用户信息、自行拼接历史记录结果模型服务被生生改成了有状态服务缓存、会话同步、权限过滤全部塞进来最后推理性能一落千丈。上下文拼装是业务服务的事模型服务只负责拿到完整输入并“算”出来。2.2 业务服务编排者的状态管理与超时控制业务服务是链路里最像“传统后端”的一环但因为它后面挂了模型服务多了一个普通的CRUD后端没有的义务控时。模型推理是慢操作。一个较长的生成任务可能是几秒到几十秒。业务服务如果采用同步等待必须给每个上游调用设置超时否则一个慢推理请求会让整个业务线程池被占满。这里要区分两种模式。如果是非流式响应超时设置要果断一些超过阈值直接返回“任务处理中”之类的异步结果让前端轮询或者回调如果是流式响应业务服务一侧要做的是“流式透传”把上游的流式数据原样转发给前端同时监控连接状态前端断开就取消上游生成任务。上下文管理也是业务服务的重头戏。多轮对话时不能简单粗暴把所有历史消息全部塞给模型要考虑窗口长度、信息重复、检索片段的位置。我一般会在业务服务里设计一套轻量的上下文组装器保留最近N轮对话的核心内容把知识库召回的片段拼接成参考材料再附上系统提示词最终形成一个发给模型服务的结构化请求。这套逻辑本身就是产品差异化的关键放在业务服务里最合适因为只有业务服务才知道产品层面需要怎样的上下文策略。业务服务还需要处理“降级”。模型服务故障时不能把错误页直接丢给用户。更稳妥的做法是模型服务接口设计成可降级的推理失败时返回一个标记业务服务捕获后走兜底话术至少保证用户会话链路不断。降级逻辑放在业务服务因为只有业务服务才能判断在什么产品场景下给用户怎样的兜底。2.3 知识库离线入库与在线检索的拆解知识库不是“把PDF转文本塞进向量库”那么简单它的核心是两套子系统离线管道的“准”和在线检索的“快”。离线入库链路通常是文档上传→解析→清洗→切分→向量化→写入向量库。每一步都有讲究。解析阶段要处理PDF、Word、Markdown不同格式的差异PDF还要处理扫描件OCR清洗阶段要去掉页眉页脚、目录、水印这些噪声切分阶段要控制chunk大小和重叠比例切得太小语义割裂切得太大检索命中不精准我的经验是普通文档800到1200字符比较稳妥配合10%到20%的重叠既能保证片段语义完整又不会让相似块过多影响召回精度。向量化阶段要做一下领域适配验证专业名词多的场景可能需要微调嵌入模型通用场景直接用现成的即可。在线检索链路则是用户问题向量化→向量库相似度召回→可选重排→返回片段。召回阶段要关注top_k和相似度阈值两个参数。top_k太小容易漏太大噪声多阈值太严召回为空太松结果全是无关片段。这些参数应该做成知识库的可配置项不同知识库独立配置。重排模型适合数据量大、答案精度要求高的场景用一个轻量重排模型把向量召回的候选集重新打分能明显提升最终答案质量代价是增加一次模型推理要在效果和成本之间权衡。权限过滤是知识库最容易漏的设计。很多团队直接把知识库做成全局共享用户A上传的文档用户B在问答里也能检索到。正确做法是每个知识片段入库时打上权限标签检索时把用户权限作为过滤条件带进去。越早设计越好否则等数据量大了再回补权限清洗成本极高。2.4 服务间接口与数据流转协议选型与链路设计三个服务之间的接口设计决定了拆分后是“如丝般顺滑”还是“四处冒烟”。我始终建议先定协议再写代码。内部接口和外部接口分开。对外只暴露业务服务的统一网关入口模型服务和知识库服务都不直接对外所有对它们的访问从网关或者业务服务内部发起。这个约束能有效防止“某些人图方便直接调模型服务”导致的权限绕行。通信格式上业务服务和模型服务之间用HTTP/JSON完全够用。gRPC的性能优势是实打实的但只在极高吞吐场景才能体现出来而且调试成本高。对大多数AI应用来说HTTP/JSON配合流式响应已经能支撑几千的并发。知识库的在线检索接口返回JSON就更好理解了。异步场景则靠消息队列连接。文件上传后不直接触发解析而是投递一个任务到队列由知识库服务的工作进程消费。这个设计的好处是削峰填谷批量导入时不打垮在线检索接口也避免了模型服务直接处理文档的复杂度。链路可观测性也得早做。每条请求从网关进来生成一个trace_id透传到模型服务和知识库服务日志统一带上这个ID。线上排查时根据trace_id就能把用户请求、推理耗时、检索结果串成一条完整链路。这个工作在拆分初期做成本很低等系统跑起来再做就要动所有服务的日志代码了。数据流转的主流脉络是这样的用户请求先进网关路由到业务服务业务服务先查知识库在线检索接口拿知识片段再把片段和历史会话组装成上下文调模型服务生成回答拿到回答后业务服务负责写会话记录、计量token消耗最后把结果返回用户。模型服务在整条链路里处于“被调用”的位置知识库则被业务服务访问三个服务的依赖方向是单向的业务服务依赖模型服务和知识库模型服务和知识库互不感知。这能让整个系统的耦合度降到最低。3. 实操过程与核心环节实现一版可以落地的拆分方案3.1 总体拓扑网关加三个核心服务加中间件动手写代码之前先把部署形态定下来。我用的参考方案是四层结构。网关层承担统一入口、路由转发和基础限流。业务服务层部署在容器环境无状态副本数可以随QPS横向扩缩。模型服务层独立部署在一台GPU机器上单进程常驻模型对外暴露统一的推理接口。知识库层由三部分组成负责离线解析入库的任务消费者、负责在线检索的查询服务、以及底层的向量库存储和对象存储。消息队列把业务服务上传的文件和知识库入库管道解耦。这套拓扑里业务服务和知识库服务都是可以弹性伸缩的“常规服务”只有模型服务是“特殊节点”因为它绑定GPU资源和模型权重扩缩容至少是分钟级。所以部署之后要重点维护两类指标模型服务所在GPU实例的显存和推理队列长度以及消息队列的积压量。这两个指标能反映整个系统健康度。3.2 模型服务最小实现FastAPI封装与流式输出给一个可以直接抄作业的模型服务最小示例。这里假设模型已经加载到一个全局对象里基于FastAPI暴露接口。from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel app FastAPI() class GenerateRequest(BaseModel): messages: list[dict] temperature: float 0.7 max_tokens: int 1024 stream: bool False async def generate_payload(request: GenerateRequest): prompt build_prompt(request.messages) if request.stream: async def event_stream(): async for chunk in model.generate_stream(prompt, temperaturerequest.temperature, max_tokensrequest.max_tokens): yield fdata: {chunk}\n\n yield data: [DONE]\n\n return StreamingResponse(event_stream(), media_typetext/event-stream) result model.generate(prompt, temperaturerequest.temperature, max_tokensrequest.max_tokens) return {choices: [{message: {content: result}}]} app.post(/v1/generate) async def generate(request: GenerateRequest): return await generate_payload(request)这里有两个细节值得解释。第一个是流式协议直接采用标准的数据流格式每条消息以“data: ”开头结束信号用特殊标记。前端可以用通用的流式事件源解析逻辑不需要定制协议。第二个是请求体里的messages结构尽量和主流模型API对齐业务服务组装上下文时按这套结构来将来换模型服务实现业务服务一行代码都不用改。生产环境里还要补三段逻辑鉴权中间件、健康检查接口、并发控制。鉴权保证只有业务服务能调它健康检查供网关判断服务存活并发控制防止突发流量把显存打爆实现方式是在请求入口加一个信号量超过并发上限直接返回“繁忙”状态码。示例代码里没有加并发控制但线上版本必须有。3.3 业务服务编排示例上下文组装与调用控制业务服务的关键在于调用模型服务和知识库的顺序、超时、降级。下面这段代码展示了核心逻辑的位置。def answer_question(user_id, question, session_id): # 1. 查知识库取知识片段 try: fragments knowledge.search(question, top_k5, user_iduser_id) except KnowledgeTimeout: fragments [] # 2. 拼装上下文 history session_store.get_recent(session_id, limit6) prompt build_chat_context(history, question, fragments) # 3. 调模型服务带超时和降级 try: answer model_client.generate(prompt, max_tokens800) except ModelTimeout: answer 这个问题我需要翻一下资料请换个问题再试。 # 4. 写会话计token session_store.append(session_id, question, answer) usage_metric.add(user_id, prompt_tokens, completion_tokens) return answer这个流程看起来平淡但每一行都有讲究。查知识库先于调模型服务因为知识片段要作为上下文的一部分传给模型知识库超时不能影响主流程失败就当没有检索到模型带着纯历史上下文也能回答只是效果可能差点模型服务超时走降级文案这个文案要设计得让用户不觉得是系统坏了而是“暂时不方便回答”。最后会话记录和计费是业务服务的责任不能漏。业务服务侧的模型调用客户端必须内置连接池和超时配置。连接池避免每次请求都新建TCP连接超时设置要分场景普通短文本生成3秒中长文本生成按max_tokens估算比如没生成一个token约0.05秒超过估算值1.5倍就中断。很多线上事故都是超时设置拍脑袋导致大量请求堆积在模型服务最终谁都处理不完。3.4 知识库入库流水线从文档到可检索索引知识库的离线流水线是我在实操中反复打磨最多的部分。给出完整的入库流程和每个环节的参数经验。文档上传后投递到消息队列消费者依次处理。解析阶段按文件类型分别处理文本类直接提取PDF要解析页面结构表格要转为Markdown或者HTML片段避免数据表格的信息被切碎。清洗阶段做规则过滤页眉页脚、导航栏、重复空白符全部干掉。切分阶段用递归字符切分器按段落优先再按句子边界修正保证片段内部语义完整。向量化阶段把整篇文档做一次并发向量化控制单批次大小避免一次性提交所有片段导致内存飙升。写入阶段在向量库里批量插入同时给片段生成唯一ID关联文档ID和权限标签。以下是切分和向量化的示意代码。def split_and_embed(doc_text, doc_id, perms): chunks recursive_split(doc_text, chunk_size1000, chunk_overlap200) batch [] for idx, chunk in enumerate(chunks): vec embed_model.encode(chunk) batch.append({ id: f{doc_id}-{idx}, doc_id: doc_id, text: chunk, vector: vec, permissions: perms, version: 1 }) vector_store.upsert(batch) doc_store.mark_indexed(doc_id, version1)代码里的version字段很重要。文档重新上传时不是先删旧向量再写新的而是带着新的version入库检索时只返回最新version的片段。这样可以避免删除操作和写入操作并发时出现一阵子的空窗期。文档下线时按doc_id删除向量再更新文档状态这个顺序不能反否则会先出现“文档已下线但还能搜到内容”的脏数据。检索侧的在线代码就更短了但参数要细心调def search(query, top_k5, user_idNone, score_threshold0.75): qvec embed_model.encode(query) hits vector_store.query(qvec, top_ktop_k*2) hits [h for h in hits if h.score score_threshold] hits [h for h in hits if user_id in h.permissions] return hits[:top_k]这里第一轮多取一倍候选再根据阈值和权限过滤最后截断到目标数量比直接取top_k更稳。score_threshold的值要根据嵌入模型调不同模型的相似度分数分布差异极大必须拿真实数据跑一遍分布再定。3.5 部署参数选择从Worker数到超时阈值部署参数直接影响拆分效果列一下实测下来比较推荐的参考值。模型服务进程数建议一个GPU实例只跑一个worker。除非推理框架能自己管理多GPU否则多worker只会带来重复的模型副本。并发控制在推理框架层设置比如vLLM的max_num_seqs初始可以设置为GPU显存和模型大小估算值再根据延迟测试调整。业务服务的实例数则不用担心它是无状态的普通服务Nginx负载均衡加水平扩容就行。知识库检索服务也类似无状态可以多副本。超时配置这块直接把经验值放出来业务服务内部调用模型服务的连接超时1秒、读超时按max_tokens估算网关层到业务服务的超时要大于业务服务到模型服务的超时差一个数量级才行Nginx的proxy_read_timeout如果开了缓冲默认60秒在长生成任务下会掐断流式连接要调到能覆盖最慢请求的1.5倍以上。健康检查的启动探针也比普通服务宽松得多模型服务冷启动可能要好几分钟探针的失败阈值要设得足够大否则每次发布都像惊悚片。4. 常见问题与排查技巧实录拆完之后踩过的坑4.1 模型服务OOM根源往往是并发控制缺失这是我见过最多的情况。模型服务没设并发上限业务服务有12个线程同时发请求每个请求都要求生成512个token显存瞬间爆炸。排查时先看GPU进程的显存曲线是不是在请求高峰时直线攀升到上限附近。解决思路不是回退架构而是加并发控制和排队。模型推理引擎通常自带队列但业务侧的调用客户端也要做并发上限比如信号量或者连接池大小限制。两层各卡一道才能避免突发流量直接把模型进程打崩。加了控制之后高并发时的表现是延迟上升而不是OOM这两者对线上体验的影响完全不同。4.2 流式响应频繁断连病根在网关超时前端接模型服务的流式输出经常跑到一半断掉。排查链路时要先分清是哪一层断的。在业务服务侧加日志能清晰看到到底是在等模型服务首包时超时还是收到首包后中途超时。如果是前者看模型服务的排队情况如果是后者九成是Nginx或者网关的proxy_read_timeout没调大流式场景下这个参数决定了一次连接最长能维持多久。修复方式是网关层统一调大流式接口的超时配置并且不建议在网关层对AI接口启用响应缓冲。缓冲会等整个请求结束后才把数据发给客户端首字延迟直接变成全部请求的总延迟体验没法看。取消缓冲之后还要注意代理侧的headers设置保证连接不被中途回收。4.3 知识库检索出来的内容驴唇不对马嘴检索结果差大多数不是模型的问题而是切分参数和检索参数不匹配。文档字段混合、大段落被切成碎片或者chunk_size设太大导致一个片段里塞了两三个主题都会让召回结果看起来离谱。排查检索效果时我会先从知识库里随手挑几段和多主题看它们是否语义完整。再把查询样本和召回样本的真实相似度分数打印出来如果分数普遍很低但实际语义相关说明嵌入模型对领域术语不敏感要考虑领域微调或者换更强的嵌入模型。如果分数挺高但结果不相关说明检索逻辑缺少重排需要用重排模型把分数重新拉平。4.4 业务服务显示超时但模型还在计算这是异步链路最隐蔽的坑。业务服务设置了3秒超时模型服务实际要算10秒请求被业务侧中断但模型服务还在继续吐结果。这种场景在长文本生成和批量任务里尤其常见后果是GPU被“幽灵请求”白占算力浪费模型服务内部队列越积越长。解法是给推理请求设计“取消”语义。业务服务超时后主动调模型服务的中止接口或者模型服务在处理请求时登记请求ID收到中止信号就停止生成。实现成本不高但能省下大量无效推理开销。从架构上这也是无状态服务设计的一部分请求标识透明地穿过链路任何环节都能拉起和取消任务。4.5 知识库数据更新后线上还是旧内容发布新文档或删除旧文档后在线检索到的内容还是老的多半是缓存或版本管理没做好。向量索引、检索结果缓存、对象存储三套系统之间存在时序差。我前面提到version字段就是为这个准备的入库时递增版本号检索时过滤旧版本删除操作按doc_id清理。缓存策略上也别做过长TTL要么在文档更新时主动刷新对应缓存键要么把缓存TTL控制在几十秒以内避免用户长时间看到过期内容。4.6 排查顺序先对齐链路再按层测试拆分后服务多了排查效率取决于日志和指标是否清晰。我每次处理线上问题都按固定顺序来先从网关拿到trace_id把链路日志完整拉出来确认耗时和失败点发生在哪一层是知识库检索超时、模型推理超时、还是业务逻辑异常再针对具体层做测试比如直接调模型服务的内部接口确认模型本身是否正常。这个顺序看着基础但很多团队不做。服务一多大家习惯性先猜模型的问题结果根因在知识库权限过滤。日志先行的习惯养成了问题定位时间至少能缩短一半。我个人实际操作的体会是拆分架构的难点从来不是技术选型而是边界意志。模型服务死活不接数据库、知识库死活不碰业务逻辑、上下文拼装死活不放模型侧这三条边界守住了系统再怎么演进都是清晰的。最后再分享一个细节技巧开始动手拆之前先写一份三个服务之间的接口定义文档哪怕只有十几个接口写完再写代码整个重构过程会顺很多。当时踩过几次边撕边改的坑之后我就再也没有跳过这一步了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →