1. 从模型选型到智能体落地:这套方案到底在解决什么问题
过去大半年,我一直在折腾 Agent 相关的项目,从最开始的玩具级 Demo 到后来真正要扛线上流量的生产系统,中间踩的坑实在太多了。很多朋友问我,Hermes 这套东西到底怎么和 Agent 工程结合起来,模型该怎么选,Function Calling 怎么调才稳,vLLM 部署要注意什么。说实话,这些问题不是一两句话能说清的,因为 Agent 工程本质上是一个系统工程,模型只是其中一环,编排、工具调用、状态管理、错误恢复,每一块都能让你掉一层皮。
我写这篇东西的目的很简单:把我从零搭一套生产级 Agent 的完整路径摊开来讲,包括模型选型时到底看哪些指标、Function Calling 的协议怎么设计才不容易崩、vLLM 部署大模型时那些官方文档不会告诉你的细节、以及 Python 侧怎么把这些东西串起来。适合谁看?如果你已经写过几个 Agent Demo,但一到生产环境就各种超时、幻觉、工具调用失败,那这篇就是写给你的。如果你刚入门,也没关系,我会把基础概念用生活化的方式讲清楚,保证你能跟上。
核心关键词我先摆出来:Hermes、Agent、Function Calling、Python、vLLM。这五个词基本覆盖了从底层推理到上层编排的全链路。Hermes 在这里我把它理解为一类具备强工具调用能力的模型系列,Agent 是最终要交付的智能体形态,Function Calling 是模型和外部世界交互的桥梁,Python 是胶水语言,vLLM 是推理加速引擎。把这五块拼起来,你就能得到一个能跑在生产环境的智能体系统。
2. 整体架构设计与模型选型思路拆解
2.1 为什么 Agent 工程不能只盯着模型看
很多人一上来就问“哪个模型最强”,这其实是个伪命题。Agent 的表现取决于三个层面的配合:模型本身的推理和工具调用能力、编排框架对状态的管控能力、以及推理服务的稳定性和延迟。我见过太多案例,模型选了个榜单第一的,结果 Function Calling 格式老是解析失败,或者并发一上来推理服务直接 OOM,整个 Agent 就废了。
所以我的思路是先把架构定下来,再倒推模型选型。一个典型的生产级 Agent 架构大概长这样:最上层是业务逻辑,中间是 Agent 编排层(负责规划、记忆、工具路由),下面是模型推理服务,最底层是各种工具和外部 API。Hermes 这类模型的价值在于它对结构化输出和工具调用的原生支持比较好,不需要你在 Prompt 里反复强调格式,省了很多 token 也降低了出错率。
2.2 模型选型的四个硬指标
选模型的时候我一般看四个东西,按优先级排:
- Function Calling 的可靠性:这是 Agent 的命根子。模型能不能稳定输出符合 JSON Schema 的调用参数,直接决定了你的工具调用成功率。我实测下来,Hermes 系列在这块的表现明显优于同尺寸的通用模型,尤其是多工具并行调用的场景。
- 上下文窗口和长文本衰减:Agent 往往需要塞入大量历史对话和工具返回结果,上下文窗口不够直接没法玩。但更关键的是长文本下的注意力衰减,有些模型标称 128K,实际到 32K 就开始胡言乱语了。
- 推理延迟和吞吐:生产环境不是单次调用,你要考虑并发。一个 70B 的模型如果单次推理要 3 秒,那 QPS 根本上不去。这时候 vLLM 的 PagedAttention 和连续批处理就派上用场了。
- 部署成本:显存占用、量化支持、是否容易做张量并行,这些都要算进去。不是所有团队都有 8 卡 A100 的预算。
2.3 vLLM 在架构中的定位
vLLM 不是唯一选择,但它是我目前用得最顺手的推理引擎。核心原因是它的PagedAttention机制把 KV Cache 的显存利用率拉高了一个档次,配合连续批处理(Continuous Batching),在同样硬件下吞吐能比朴素实现高好几倍。对于 Agent 这种请求长度差异极大的场景,vLLM 的调度器能动态合并请求,避免长请求阻塞短请求。
这里有个细节值得说:vLLM 的调度逻辑是分层的,它会把等待队列里的请求按 token 预算打包成一个 batch,然后交给模型执行。如果你的 Agent 请求里有的很短(比如简单的意图识别)有的很长(比如带大量工具返回的总结),vLLM 能自动做优先级和打包优化,这一点在自研推理服务里很难做好。
3. Function Calling 协议设计与核心细节解析
3.1 Function Calling 到底是怎么工作的
很多人把 Function Calling 当成黑魔法,其实原理很朴素。你在请求里附带一个工具列表,每个工具用 JSON Schema 描述它的名字、参数和类型。模型在生成的时候,如果判断需要调用工具,就不会输出普通文本,而是输出一段结构化的调用请求,比如工具名加参数。你的代码解析这段结构,执行真正的函数,再把结果塞回对话历史,让模型继续生成。
关键在于“结构化输出”的稳定性。早期做法是在 Prompt 里写“请以 JSON 格式输出”,然后正则提取,这种做法在生产环境基本不可靠,因为模型可能加个前缀、少个括号、或者把字符串引号搞错。Hermes 这类模型的好处是它在训练阶段就强化了工具调用的格式遵循,配合 vLLM 的 guided decoding(引导解码),可以强制模型只能输出符合 Schema 的 token,从根本上杜绝格式错误。
3.2 工具 Schema 设计的五个坑
我踩过的坑基本都集中在 Schema 设计上,这里列几个最典型的:
- 参数类型过于复杂:嵌套对象和数组虽然 Schema 支持,但模型解析起来容易出错。我的经验是尽量扁平化,超过两层的嵌套就拆成多个工具。
- 枚举值不明确:如果一个参数是枚举,一定要把所有可能值列全,并且加上描述。模型看不到枚举值就会瞎猜。
- 必填项太多:必填参数越多,模型漏填的概率越高。能设默认值的就设默认值,能推断的就别让模型填。
- 工具描述太短:工具描述是模型判断“什么时候该调用这个工具”的唯一依据。描述里要写清楚使用场景、不适用场景、以及和其他工具的区别。
- 工具数量爆炸:一次给模型几十个工具,它的选择准确率会断崖式下降。我的做法是按场景分组,每次只暴露当前场景相关的 5 到 8 个工具。
3.3 用 vLLM 的 guided decoding 兜底
vLLM 支持基于 JSON Schema 的引导解码,这个功能在 Agent 场景下简直是救命稻草。你可以在请求里指定guided_json参数,vLLM 会在解码时动态构建一个有限状态机,只允许模型输出符合 Schema 的 token 序列。这样即使模型本身想“跑偏”,也会被强行拉回来。
配置的时候要注意,guided decoding 会带来一定的解码开销,因为每步都要做状态转移。对于工具调用这种短输出场景,开销可以忽略;但如果你用它来约束长文本生成,延迟会明显上升。我的建议是只在工具调用节点开启,普通对话节点关掉。
4. 基于 vLLM 的推理服务部署实操
4.1 环境准备与镜像选择
部署 vLLM 最省事的方式是用官方 Docker 镜像。这里有个常见误区:很多人以为镜像里带了模型,其实官方镜像只带推理引擎,模型要你自己挂载或者从模型仓库拉取。我一般会把模型权重提前下载到宿主机,然后通过 volume 挂载进容器,这样启动快也不用每次重新下载。
镜像版本的选择要看你的模型和 CUDA 版本。太新的镜像可能和你的驱动不兼容,太旧的又不支持新模型的架构。我的经验是选一个稳定的大版本,比如 v0.27.x 系列,然后在这个系列里挑最新的小版本。启动命令大概是这样:
docker run --gpus all \ -v /data/models:/models \ -p 8000:8000 \ --shm-size 16g \ vllm/vllm-openai:latest \ --model /models/your-model \ --served-model-name hermes-agent \ --max-model-len 32768 \ --gpu-memory-utilization 0.9 \ --enable-auto-tool-choice \ --tool-call-parser hermes几个参数值得展开说。--shm-size一定要给够,vLLM 在多进程通信时对共享内存需求很大,默认的 64M 根本不够,会直接崩。--gpu-memory-utilization控制显存占用比例,0.9 是个比较稳的值,留一点给系统。--enable-auto-tool-choice和--tool-call-parser是开启工具调用支持的关键,不同模型的解析器不一样,Hermes 系列用hermes解析器。
4.2 显存估算与并行策略
部署前一定要算显存,不然启动到一半 OOM 很浪费时间。粗略公式是:模型参数量乘以精度字节数,再加上 KV Cache。比如一个 7B 模型用 FP16,权重占 14GB,KV Cache 取决于并发数和上下文长度。如果上下文 32K、并发 16,KV Cache 大概要 8 到 10GB,总共 24GB 左右,一张 3090 或 4090 勉强够。
如果模型太大单卡放不下,就要用张量并行。vLLM 支持--tensor-parallel-size参数,把模型切到多张卡上。但要注意,张量并行会带来卡间通信开销,卡越多效率越低。我的经验是 2 卡并行的效率大概是单卡的 1.7 倍,4 卡只有 2.8 倍左右,所以能用单卡就别用多卡。
4.3 服务健康检查与压测
服务起来之后别急着接业务,先做健康检查和压测。健康检查直接打/health接口,返回 200 就说明服务活着。压测我一般用locust或者自己写个 Python 脚本,模拟不同长度的请求并发打过去,观察 P99 延迟和吞吐。
这里有个坑:vLLM 的默认调度策略是 FCFS(先来先服务),如果前面有个超长请求,后面的短请求会被阻塞。可以通过调整--scheduler-policy参数改成优先级调度,或者在上层做请求分流,把长短请求打到不同的实例上。
5. Python 侧 Agent 编排的完整实现
5.1 对话循环与状态管理
Agent 的核心是一个对话循环:接收用户输入,调用模型,如果模型返回工具调用就执行工具,把结果塞回历史,再调用模型,直到模型返回普通文本为止。这个循环看起来简单,但状态管理很容易出问题。
我用一个AgentState类来管理所有状态,包括对话历史、工具调用记录、当前步骤数、以及各种中间变量。关键是要设置最大步数限制,防止模型陷入死循环。我一般设 10 步,超过就强制终止并返回当前结果。另外,工具调用的结果要截断,不能把整个 API 返回的几万字符全塞回去,否则上下文瞬间爆掉。
class AgentState: def __init__(self, max_steps=10, max_tool_result_len=2000): self.messages = [] self.step = 0 self.max_steps = max_steps self.max_tool_result_len = max_tool_result_len def add_tool_result(self, result): text = str(result) if len(text) > self.max_tool_result_len: text = text[:self.max_tool_result_len] + "...[truncated]" self.messages.append({"role": "tool", "content": text})5.2 工具注册与路由
工具注册我推荐用装饰器模式,这样新增工具只要写个函数加个装饰器就行,不用改路由逻辑。每个工具要声明它的 Schema,包括名字、描述、参数。路由的时候根据模型返回的工具名去注册表里查,找不到就返回错误信息让模型重新决策。
TOOL_REGISTRY = {} def tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] = { "function": func, "schema": { "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }, } return func return decorator @tool( name="get_weather", description="查询指定城市的当前天气,适用于用户询问天气情况时", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如北京"}, }, "required": ["city"], }, ) def get_weather(city): return {"city": city, "temp": 25, "condition": "晴"}5.3 错误处理与重试机制
生产环境里工具调用失败是常态,网络超时、API 限流、参数错误都会发生。我的做法是给每个工具调用包一层重试,最多重试两次,指数退避。如果还是失败,就把错误信息作为工具结果返回给模型,让模型决定是换个工具还是直接告诉用户失败。
这里有个细节:错误信息要写得对模型友好。不要直接抛 Python 的 traceback,那对模型来说是噪音。要写成“调用 get_weather 失败,原因:城市名称无法识别,请检查后重试”这种结构化描述,模型才能理解并做出正确决策。
6. 常见问题与排查技巧实录
6.1 工具调用格式解析失败
这是最高频的问题。表现是模型返回的文本里工具调用格式不完整,或者参数 JSON 解析报错。排查思路分三步:先看是不是没开 guided decoding,开了的话格式错误率应该极低;再看 Schema 是不是太复杂,简化后重试;最后看模型本身是不是不支持工具调用,换个模型验证。
我遇到过一次很隐蔽的情况:模型输出的 JSON 里有个不可见字符,导致解析失败。后来在解析前加了一步清洗,把所有非 ASCII 可见字符过滤掉才解决。这种问题官方文档根本不会提,只能靠实际踩坑。
6.2 推理服务 OOM 或响应超时
OOM 一般是显存估算不足或者并发太高。先降--gpu-memory-utilization,再降--max-num-seqs(最大并发序列数)。响应超时则要看是不是有长请求阻塞,可以开启 chunked prefill,让长请求分块处理,不阻塞短请求。
6.3 模型陷入循环或幻觉
Agent 循环调用同一个工具、或者编造不存在的工具名,都是常见问题。前者一般是工具返回结果没有提供足够信息,模型觉得没解决问题就反复调用。解决方法是优化工具返回,或者在 Prompt 里明确“如果工具返回结果已足够,请直接回答”。后者则是工具列表和 Prompt 不一致,检查一下注册的工具和传给模型的 Schema 是否同步。
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 工具调用格式错误 | 未开引导解码 | 检查请求参数 | 开启 guided_json |
| 参数解析失败 | Schema 过复杂 | 简化嵌套结构 | 扁平化参数 |
| 服务 OOM | 显存不足 | 查看显存占用 | 降并发或量化 |
| 响应超时 | 长请求阻塞 | 观察请求分布 | 开启 chunked prefill |
| 模型循环调用 | 工具结果不足 | 检查返回内容 | 优化返回或加提示 |
| 编造工具名 | 列表不同步 | 对比注册表 | 同步 Schema |
6.4 实操心得三条
第一,永远不要相信模型的输出格式,哪怕它标称支持 Function Calling,也要在代码侧做校验和兜底。第二,工具描述要像写给新人看一样详细,模型对描述的理解能力远不如人类,模糊的描述会导致错误调用。第三,压测要在上线前做,我见过太多团队 Demo 跑得好好的,一上生产就被并发打崩,问题全出在推理服务的调度上。
7. 生产级部署的扩展与优化方向
7.1 多实例与负载均衡
单实例 vLLM 总有性能上限,生产环境一般要部署多个实例,前面挂个负载均衡。但 Agent 场景有个特殊性:同一个会话的请求最好打到同一个实例,因为 KV Cache 是实例本地的,跨实例会丢失缓存导致重复计算。解决方案是在负载均衡层做会话粘性,根据会话 ID 哈希路由。
7.2 量化与成本优化
如果预算有限,量化是最直接的降本手段。vLLM 支持 AWQ 和 GPTQ 量化,4bit 量化能把显存占用降到 FP16 的四分之一左右,精度损失在可接受范围内。但要注意,量化后的模型工具调用能力可能会下降,需要重新做一轮评测。我的经验是 7B 模型量化后影响不大,70B 模型量化后复杂推理会明显变差。
7.3 监控与可观测性
生产系统没有监控就是裸奔。我一般会采集几个核心指标:请求延迟(P50/P99)、工具调用成功率、模型输出 token 数、显存占用、以及每个工具的调用频次。这些指标能帮你快速定位问题,比如工具调用成功率突然下降,大概率是某个工具挂了或者 Schema 改了没同步。
监控数据还可以反哺优化。比如你发现某个工具调用频次极高但成功率低,那就要考虑是不是工具描述有问题,或者这个工具本身设计得不合理。这种数据驱动的优化比拍脑袋改 Prompt 有效得多。
7.4 记忆与上下文管理
Agent 跑久了上下文会越来越长,最终超出模型窗口。我的做法是分层记忆:短期记忆保留最近几轮对话,长期记忆把重要信息摘要后存到向量库,需要时检索回来。摘要的时机很关键,太早会丢信息,太晚上下文已经爆了。我一般在上下文用到 70% 窗口时触发摘要,把最早的几轮对话压缩成一段摘要。
向量库的选择上,轻量场景用 FAISS 就够了,要持久化和分布式就上 Milvus 或 Qdrant。嵌入模型可以用 vLLM 一起部署,这样推理和嵌入共享 GPU,省资源。不过要注意显存分配,嵌入模型虽然小,但也会占一部分。
这套东西我从零搭到现在稳定运行,前后迭代了十几个版本,最大的体会是:Agent 工程的难点从来不在模型本身,而在模型之外的系统工程。模型选型、推理部署、协议设计、错误处理、监控运维,每一环都能决定最终成败。把每一环都做扎实,Agent 才能真正从 Demo 走向生产。