大型语言模型(LLM)正迅速渗透到我们生活的方方面面,从日常应用程序到尖端工具,无处不在。使用它们很简单,但如果需要在生产环境中运行LLM,情况就会变得复杂起来。本文将分享我们在构建自家LLM推理系统时积累的经验,重点介绍模型存储与部署、服务架构设计,以及应对实际问题的解决方案,例如路由、流式传输和微服务管理。构建过程充满挑战,但我们最终构建了一个可靠的系统,并总结出了许多值得分享的经验。
1. 引言:自托管LLM的必要性与优势
在众多LLM应用中,检索增强生成(RAG)、工具调用和多智能体协议至关重要,但它们都运行在核心引擎——基础LLM之上。许多项目依赖于外部供应商,例如OpenAI、Gemini或Anthropic,这对于大多数用例来说已经足够。然而,在某些情况下,这会迅速成为一个问题。例如,如果供应商宕机怎么办?如果您需要完全控制延迟、定价或正常运行时间怎么办?最重要的是——如果您关心隐私,并且无法将用户数据发送给第三方怎么办?
这就是自托管变得至关重要的原因。自托管LLM,无论是预训练模型还是微调模型,都提供了控制权、安全性,以及根据特定业务需求定制模型的能力。构建这样一个系统并不需要庞大的团队或大量资源。我们用有限的预算、一个小型团队和几个节点就完成了它。这种约束影响了我们的架构决策,要求我们专注于实用性和效率。
2. 核心组件:系统架构概览
我们的LLM推理系统由几个核心组件组成,共同构成了系统的骨干:
- 格式和编码 (Schema and Data Encoding): 确保服务之间拥有一种共享的“语言”,包括一致的请求/响应格式、生成参数模式、对话历史结构和序列化机制,使其在前端、后端和模型运行器之间无缝工作。
- 流式传输和路由 (Streaming and Routing): 处理多个模型、请求类型和主机优先级需要深思熟虑的路由决策。我们将详细介绍如何将传入的用户请求路由到适当的工作节点,以及如何将响应流式传输回客户端。
- 模型存储和部署 (Model Storage and Deployment): 模型存储在哪里,以及如何为生产环境做好准备?
- 推理 (Inference): 讨论要执行的关键测试,包括确保模型的可靠性。
- 可观测性 (Observability): 如何知道系统是否正常工作?我们将展示我们跟踪的指标、如何监控故障以及我们用于确保系统健康和可靠性的探测。
3. 数据格式与编码:构建统一的“语言”
为数据传输选择正确的模式至关重要。跨服务的共享格式可以简化集成,减少错误并提高适应性。我们的目标是设计一个系统,使其可以与自托管模型和外部供应商无缝协作,而无需向用户公开差异。
- 为什么模式设计很重要? 目前,LLM数据交换没有通用的标准。许多供应商遵循类似于OpenAI的模式,而其他供应商(如Claude或Gemini)则引入了细微但重要的差异。虽然坚持预定义供应商的模式有其优点,例如获得经过充分测试的稳定API,并可以依赖现有的SDK和工具,但也有实际的缺点:供应商锁定、灵活性受限以及面临不受控制的重大更改或弃用。
为了解决这个问题,我们选择定义自己的内部数据模型——一种围绕我们的需求设计的模式,我们可以根据需要在各种外部格式之间进行映射。
-
内部模式设计: 我们的输入模式主要包含以下关键组件:
- Model: 用作路由键,充当路由标识符,允许系统将请求定向到适当的工作节点。
- Generation Parameters: 核心模型设置,例如温度、top_p、max_tokens。
- Messages: 对话历史记录和提示有效负载。
- Tools: 模型可能使用的工具的定义。
例如,我们使用类似Pydantic的格式来表示我们的
ChatCompletionRequest
,其中包含model
、messages
、generation_parameters
和tools
字段。GenerationParameters
字段包含temperature
、top_p
和max_tokens
等核心模型设置,以及一个provider_extensions
字典,用于存储特定于提供商的扩展字段。 -
与第三方供应商合作: 尽管我们依赖于自己的内部基础设施,但在某些情况下,外部模型仍然发挥作用:
- 数据科学团队用于原型设计和实验的合成数据生成。
- 某些专有模型在开箱即用时性能更好的通用任务。
- 隐私、延迟或基础设施控制不太重要的非敏感用例。
与外部供应商的总体通信流程如下:
- 专门的LLM-Gateway服务负责与提供商通信,并以我们的模式格式接收用户请求。
- 该请求被转换为提供商特定的格式,包括任何
provider_extensions
。 - 外部提供商处理该请求并返回响应。
- LLM-Gateway服务接收响应并将其映射回我们的标准化响应模式。
4. 流式传输格式:实现低延迟的实时响应
LLM响应是增量生成的——逐个token生成——然后聚合为块以实现高效传输。从用户的角度来看,无论是通过浏览器、移动应用程序还是终端,体验都必须保持流畅和响应迅速。这需要一种支持低延迟、实时流式传输的传输机制。
-
SSE (Server-Sent Events) 胜过 WebSockets: 虽然WebSockets和SSE都是可行的选择,但SSE是标准LLM推理(尤其是OpenAI兼容的API和类似系统)中更常用的解决方案。这归因于几个实际优势:简单性、兼容性、单向流和代理友好性。
-
响应流内容: SSE选定为传输层后,下一步是定义要包含在流中的数据。有效的流式传输需要的不仅仅是原始文本——它需要提供足够的结构、元数据和上下文来支持下游消费者,例如用户界面和自动化工具。流必须包含以下信息:
- 标头级元数据:基本识别信息,例如请求ID。
- 实际内容块:核心输出——模型生成的token或字符串——作为序列(n)增量传递,逐块流式传输。每个生成可以包含多个序列(例如,n=2,n=4)。这些序列独立生成并并行流式传输,每个序列都分解为自己的增量块集。
- 使用情况和Token级别元数据:包括生成的token数、时间数据和可选诊断信息,例如logprobs或推理跟踪。这些可用于计费、调试或模型评估。
我们的流设计旨在:结构化、可扩展和健壮。为了使流健壮且更易于解析,我们选择明确地为整个生成和每个单独的序列发出Start和Finish事件信号,而不是依赖隐式机制,例如null检查、EOF或magic token。
例如,根据OpenAI API的规范,单个生成块可能在choices数组中包含多个序列。尽管在实践中,单个块通常只包含一个delta,但该格式允许每个块进行多个序列更新。重要的是要考虑到这一点,因为未来的更新可能会更广泛地使用此功能。
-
错误处理: 我们还引入了一个额外的Error块,其中包含有关故障的结构化信息。某些错误(例如,格式错误的请求或授权问题)可以直接通过标准HTTP响应代码浮出水面。但是,如果在生成过程中发生错误,我们有两个选择:要么突然终止HTTP流,要么发出格式正确的SSE错误事件。我们选择了后者。突然关闭连接使客户端难以区分网络问题和实际的模型/服务故障。通过使用专用错误块,我们可以更可靠地检测和传播流式传输期间的问题。
5. 后端服务与请求流程:构建可扩展的推理引擎
系统的中心是一个入口点:LLM-Gateway。它处理基本问题,例如身份验证、使用情况跟踪和配额实施、请求格式化以及基于指定模型的路由。虽然看起来Gateway承担了很多责任,但每项任务都有意简单且模块化。对于外部供应商,它会根据他们的API调整请求,并将响应映射回统一格式。对于自托管模型,请求使用我们自己的统一模式直接路由到内部系统。这种设计允许通过一致的接口无缝支持外部和内部模型。
-
自托管模型: 虽然SSE非常适合将响应流式传输到最终用户,但它不是内部后端通信的实用选择。当请求到达时,必须将其路由到合适的工作节点以进行模型推理,并将结果流式传输回来。
我们的内部基础设施需要支持:
- 优先级感知调度:请求可能具有不同的紧急程度(例如,交互式与批量式),必须首先处理高优先级任务。
- 硬件感知路由:某些节点在更高性能的GPU上运行,应优先选择;其他节点充当溢出容量。
- 特定于模型的调度:每个工作节点都配置为仅支持一部分模型,具体取决于硬件兼容性和资源约束。
为了满足这些要求,我们使用消息代理将任务路由与结果交付分离。这种设计在不同的负载和路由条件下提供了更好的灵活性和弹性。我们为此目的使用RabbitMQ,尽管其他代理也可以根据您的延迟、吞吐量和运营偏好来使用。RabbitMQ是自然的选择,因为它具有成熟性并且与我们现有的工具保持一致。
-
使用RabbitMQ实现设计: 每个请求队列都是一个常规的RabbitMQ队列,专用于处理单个模型类型。我们需要优先级感知调度,这可以使用消息优先级来实现。在这种设置中,具有较高优先级值的消息会在较低优先级的消息之前传递和处理。对于硬件感知路由,其中消息应首先定向到性能最高的可用节点,消费者优先级可以提供帮助。只要消费者处于活动状态,具有较高优先级的消费者就会收到消息;只有当优先级较高的消费者被阻止或不可用时,优先级较低的消费者才会收到消息。
到目前为止,我们已经介绍了如何发布任务,但是如何处理流式响应?第一步是了解RabbitMQ中临时队列的工作方式。代理支持一种称为独占队列的概念,该队列绑定到单个连接,并在该连接关闭时自动删除。这使它们非常适合我们的设置。
我们为每个Scheduler服务副本创建一个独占队列,确保在副本关闭时自动清除它。但是,这带来了一个挑战:虽然每个服务副本都有一个RabbitMQ队列,但它必须并行处理许多请求。
为了解决这个问题,我们将RabbitMQ队列视为传输层,将响应路由到正确的Scheduler副本。每个用户请求都分配一个唯一的标识符,该标识符包含在每个响应块中。在Scheduler内部,我们维护一个额外的内存路由层,其中包含短期的内存队列——每个活动请求一个。传入的块根据标识符与这些队列匹配,并相应地转发。这些内存队列在请求完成后将被丢弃,而RabbitMQ队列将保留服务副本的生命周期。
Scheduler中的中央调度程序将块分派到适当的内存队列,每个队列都由专用处理程序管理。然后,处理程序使用SSE协议将块流式传输给用户。
6. 推理:选择合适的推理框架
有几个成熟的框架可用于高效的LLM推理,例如vLLM和SGLANG。这些系统旨在并行处理多个序列并实时生成响应token,通常具有连续批处理和GPU内存优化等功能。在我们的设置中,我们使用vLLM作为核心推理引擎,并进行了一些自定义修改:
- 自定义beam search实现——更好地满足我们的生成逻辑并支持结构化约束。
- 支持结构化输出模式——允许模型返回符合业务特定格式的输出。
通过经验,我们了解到,即使是细微的库更新也可能会显着改变模型行为——无论是在输出质量、确定性还是并发行为方面。因此,我们建立了一个强大的测试管道:
- 压力测试以发现并发问题、内存泄漏或稳定性回归。
- 确定性测试以确保固定种子和参数集的一致输出。
- 参数网格测试以覆盖广泛的生成设置,而不会过度。
7. 存储和部署:解决模型权重存储的难题
大多数现代系统都在容器化环境中运行——无论是在云中还是在Kubernetes(K8s)中。虽然此设置适用于典型的后端服务,但它引入了有关模型权重存储的挑战。LLM模型的大小可以是数十甚至数百GB,并且将模型权重直接烘焙到Docker镜像中会很快出现问题:
- 构建速度慢:即使使用多阶段构建和缓存,在构建阶段传输大型模型文件也会大大增加CI时间。
- 部署速度慢:每次推出都需要拉取大量的镜像,这可能需要几分钟,并导致停机或延迟。
- 资源效率低下:Docker注册表和Kubernetes节点都没有针对处理极大的镜像进行优化,从而导致存储使用量膨胀和带宽压力。
为了解决这个问题,我们将模型存储与Docker镜像生命周期分离。我们的模型存储在外部S3兼容对象存储中,并在推理服务启动之前获取。为了缩短启动时间并避免冗余下载,我们还在每个节点上使用本地持久卷(PVC)来缓存模型权重。
8. 可观测性:监控系统健康与性能
像这样的系统——建立在流式传输、消息队列和实时token生成之上——需要强大的可观测性才能确保大规模的可靠性和性能。
除了标准的服务级别指标(CPU、内存、错误率等)之外,我们发现监控以下内容至关重要:
- 队列深度、消息积压和消费者数量——监控待处理消息的数量、当前队列大小和活动消费者数量有助于检测任务分配瓶颈和工作程序利用率的不平衡。
- Token/chunk吞吐量——跟踪每秒生成的token或响应块的数量有助于识别延迟或吞吐量回归。
- 分布式跟踪——查明请求在组件(网关、代理、工作程序等)中失败或停滞的位置。
- 推理引擎健康检查——由于推理过程可能在极少数情况下崩溃(例如,错误的输入或极端的参数值),因此主动监控活动性和就绪情况至关重要。
9. 未来改进方向
虽然我们的系统已经可以投入生产,但仍存在重要的挑战和优化机会:
- 使用分布式KV-cache来提高推理性能。
- 支持请求取消以在不再需要输出时节省计算资源。
- 为数据科学团队创建一个简单的模型交付管道。
10. 结论:构建可维护且适应性强的LLM服务系统
构建可靠且与供应商无关的LLM服务系统乍一看似乎很复杂,但不需要重新发明轮子。每个组件——通过SSE进行流式传输、通过消息代理进行任务分发以及由vLLM等运行时处理的推理——都具有明确的目的,并且基于现有、良好支持的工具。通过正确的结构,可以创建一个可维护且适应性强的设置,以满足生产要求,而无需不必要的复杂性。