在聊 Agentic LLM Serving 之前,我先讲一个真实场景:上个月我把一批多步推理任务迁到生产集群,结果发现编排层和推理引擎之间像隔着一层毛玻璃——明明跑的是同一套系统,Harness 不知道引擎正卡在哪一步,引擎也不知道 Harness 到底还有没有下一步请求。过去单轮对话时代,一个 HTTP 请求对应一个响应就完事了;现在 Agent 需要动态停止、插入工具结果、维护长上下文,这套老玩法就跟不上趟了。
这个「听得见但听不懂」的问题,最后靠一套名为HEAR的协议解决了。HEAR 全称是 Harness-to-Engine Asynchronous Reporting,它的核心思路很简单:让 Agent Harnesses 和 Inference Engines 之间形成一个双向、带外、可观测的通信通道。今天这篇不写概念,我直接把设计思路、部署方案和踩过的坑全盘端出来,如果你正在搭自己的 Agent Serving 栈,可以少折腾几个星期。
1. 问题拆解:为什么如今的两层架构“互相失聪”
1.1 两个世界的分裂:编排层与推理层各自为政
先明确这里说的Agent Harnesses是什么。在 LLM 应用里,它指负责编排 Agent 行为的那个层:拆解任务、决定何时调用工具、维护对话历史、判断是否继续生成。常见的例子包括 LangChain/LlamaIndex 这类框架,也包括你自研的任务调度服务。它不是模型本身,而是围绕模型的“副驾驶”。
Inference Engines则是指真正跑模型推理的服务层,比如 vLLM、TensorRT-LLM、TGI、Ollama 这类。它们负责加载权重、计算注意力、管理 KV cache、把 token 吐出来。
理论上这两层是上下游关系,但实际工程里它们往往各自为政——Harness 只负责发请求,Engine 只负责回 token。问题是 Agent 场景早就不是“发一次请求、收一段完整回复”这么简单了。一个典型 Agent 循环是这样:Harness 让模型生成一段思考,发现需要调用搜索工具,于是停下来,把工具结果拼回去,再发第二个请求让模型继续。这里面的每个中断、每次工具结果插入、每段上下文增长,都需要一个可靠的信息同步机制。
可现状是,两边只能靠隐式约定:“我再发一次请求就是继续”“你看到[TOOL_CALL]就应当停”。这种约定在单线程 demo 里没问题,一旦并发上来、上下文超过引擎预分配长度、或者某个请求意外中断,整个链路就开始报各种难以复现的错误。核心原因就是——Harness 不知道引擎内部发生了什么,引擎也不知道 Harness 的意图。
1.2 无聊但致命的痛点:重复计算、上下文截断、状态不同步
这些问题听起来不性感,但在生产环境里每个都是事故级烦恼。
第一个是重复计算浪费。Agent 的上下文是逐轮增长的,很多实现为了避免 KV cache 积累带来的调度复杂性,会在每轮工具调用后把“全部历史消息”重新发给引擎。引擎就得重新 prefill 一遍前面所有 token,首 token 时延被拉到线性增长。我见过一个 5 轮工具调用的任务,第 5 轮的 prefill 耗时比第 1 轮多 7 倍,这就是白白烧 GPU 算力。
第二个是上下文长度截断的不可预测性。Agent 对话经常爆上下文,工程上的常规操作是自动截断最老的轮次。问题在于,Harness 截断后,Engine 并不知道上下文发生了 shift,它拿到的可能是错位的 KV cache。你如果不告诉引擎“当前请求的上下文相对上一个请求少了开头几段”,引擎只能傻乎乎从零 prefill——又回到重复计算。而如果引擎侧有前缀缓存,它又无法判断 Harness 给的消息序列是不是和之前的请求共享同一个前缀。
第三个是状态不同步导致错误归因。Agent 生成到一半,用户取消任务,Harness 发送 abort 请求,但引擎可能已经在调度队列里排了前一个 Agent 的请求;又比如 Agent 生成了模型认为完事的结束标记,Harness 这边却还等着工具结果,导致两边上下文里出现同一个“幻影消息”。这类问题你靠日志根本定位不出来,因为日志分别记录在两侧,时间戳对不上。
我用一个比喻总结:传统 LLM Serving 像对讲机,按下说话,说完松开,简单可靠;Agentic Serving 像两个人通电话,不仅要说话,还得听对方呼吸声,判断对方是不是走神了,偶尔还得抢话。而 HEAR 就是给电话加上“已读回执”和“呼吸监测”的协议。
2. HEAR 协议核心设计思路
2.1 三件套:控制通道、事件通道、健康通道
我当时设计 HEAR 时,没有另起炉灶搞一套全新的全家桶,而是把它拆成三个并行通道,每个通道解决一类具体问题。
第一个是Control Channel,走常规的 HTTP/gRPC 推理请求。它比标准请求多了一个agent_context_id字段,用来标定这是哪个 Agent 任务的哪一轮。这个 ID 是串联后续所有事件的关键,没有它,事件报过来你都不知道该挂到哪个会话上。
第二个是Event Channel,走全双工长连接(WebSocket 或 gRPC stream)。引擎侧在这里主动上报事件,包括:
generation_progress:一次生成过程中 token 级别的进度回调tool_call_detected:引擎发现模型输出了工具调用标记turn_end:这一轮生成的结束原因(是达到max_tokens还是模型发出结束符)context_truncation:引擎内部因为超长而主动丢弃了部分前缀
Event Channel 的意义在于,它把原来只能靠“解析 SSE 文本末尾”的事件,变成了结构化的、可确认的消息。Harness 可以针对不同事件做精确处理——比如收到tool_call_detected就立刻启动工具执行,而不是傻等整轮生成完再解析。
第三个是Health Channel。这是很多人忽略的:Production 环境里,Harness 在发请求前需要知道引擎当前的负载、队列深度、KV cache 命中率、是否正在滚一个超大请求。Health Channel 里引擎以固定间隔上报heartbeat,内容包括当前排队请求数、GPU 利用率、可用 KV 槽位。Harness 可以靠这个做路由——把新 Agent 请求发给最空闲的引擎副本。
这三个通道相互独立,但又共用同一个agent_context_id贯穿起来,形成一个可观测的闭环。
2.2 为什么必须“带外”通道,而不是复用生成响应流
这里很多人会问:我现在的 vLLM 已经支持 stream 输出了啊,你把事件混在 SSE 里不就行了?还省了一条连接。
理论上可以,但实践下来你会被恶心死。原因有三点。
第一,流式响应是单一文本流,它的内容格式由模型输出决定。如果模型生成了一段包含[TOOL_CALL] beginning...这样的文本,而你恰好用“检测子串”来当工具调用信号,那当模型真的想引用这段字符串时,你的 harness 就疯了。事件通道的价值在于,它是显式协议消息,不是模型生成的自由文本,因此不会被“提示词注入”或“幻觉输出”干扰。
第二,SSE 是单向的,服务端往客户端推数据容易,但客户端往服务端反向控制就麻烦了。HEAR 的事件通道用全双工连接,Harness 可以在这个通道上发送cancel、pause、resume指令,引擎可以实时响应控制,而不是只能默默把长请求跑到结束才能接受下一个。
第三,混合协议会把 output token 消耗进业务逻辑里。如果你把 heartbeat 和事件细节混在同一个文本流,那模型输出的 token 计数就不干净了,计费、日志、重放全部会出问题。职业素养要求我们把“模型采样内容”和“基础设施信号”分开成两个 pipeline,这就是带外通道存在的根本原因。
2.3 消息格式与序列化:JSON 还是 Protobuf
HEAR 最初我用了 JSON,因为调试方便——每个消息都是一个自描述的 JSON 对象,WebSocket 直接丢过来,json.loads就行。后来并发一上来,JSON 的解析开销能占到事件通道 CPU 的 4%,而且 JSON 没有 schema 约束,上游一个字段拼错,下游拿到None只能靠 if 判断兜底。
于是我把 Event Channel 和 Health Channel 改成Protobuf,Control Channel 保持 JSON(方便和现有推理请求兼容)。每条事件消息会带version和schema_id,这样跨版本升级不会炸掉老 harness。
核心消息长这样(Proto 定义片段):
message HAResponse { string agent_context_id = 1; string turn_id = 2; oneof event_type { GenerationProgress progress = 3; ToolCallDetected tool_call = 4; TurnEnd turn_end = 5; ContextTruncated truncated = 6; } uint64 timestamp_ms = 7; } message Heartbeat { string engine_id = 1; uint32 queue_depth = 2; float gpu_util = 3; uint32 available_kv_slots = 4; }我个人的建议是,对外协议文档用 JSON 描述,线上传输用 Protobuf + gRPC stream。这样既好读,又高效。
3. 落地实操:在你的 Serving 栈上部署 HEAR
3.1 接入 vLLM:装插件、开配置、看日志
如果你用的是 vLLM(我生产环境的主力),接入 HEAR 其实非常简单,因为它已经支持自定义AsyncEngineBackend回调。我在 vLLM 侧写了一个hear_plugin,启动时挂上,配置文件长这样:
hear: enabled: true control_port: 8000 event_port: 8001 health_port: 8002 context_id_header: "X-Agent-Context-ID" heartbeat_interval_ms: 2000 use_protobuf: true启动命令不变,唯一要留意的是端口规划。control_port是原有推理端口,event_port和health_port必须显式暴露给 Harness 侧,并且最好通过内部 k8s service 访问,不要直接暴露公网。
vLLM 侧插件原理并不复杂:拦截引擎内部的sequence_finished信号,把结束原因、token 统计、工具调用位置封装成事件发出去。我在内部实现里用的是AsyncEngineCore的add_event_handler钩子,关键代码可以简化成:
async def on_iter_result(self, seq_group, result): if result.is_agent_event: event = self.make_hear_event(seq_group, result) await self.event_sender.push(event)部署后第一件事不是压测,而是看事件通道的日志,确认每个请求都有agent_context_id回传。常见一个坑:如果你的 vLLM 版本较老,不支持自定义钩子,就得改用正则拦截输出文本。我强烈不建议这条路——太脆了,模型稍微改个格式就 GG。
3.2 Harness 侧:用 Python 包组装消息
Harness 侧的接入更直接,我写了一个轻量客户端库,核心就一个类:
from hearsdk import HEARClient client = HEARClient( control_url="http://engine:8000", event_url="ws://engine:8001/ws", health_url="grpc://engine:8002", context_id="agent_ctx_0789", ) client.send_prompt([{"role": "user", "content": "写一篇产品文案"}]) @client.on("tool_call_detected") def handle_tool_call(event): tool_name = event["tool_name"] result = call_tool(tool_name, event["arguments"]) client.append_tool_result(result) @client.on("turn_end") def handle_turn_end(event): if event["reason"] == "tool_call": client.resume_generation() else: client.finish_task(event["text"])注意,Harness 侧不要觉得有了tool_call_detected事件就可以丢掉turn_end。实际中还有recontextualize的情况——有的 Agent 循环在工具返回后必须突然调整 prompt 模板,这时turn_end事件里的done_condition字段能告诉你引擎在哪一步停的,方便你决定是直接续 token 还是在插入工具结果后换一段 prompt 重新 prefill。
如果你用的框架是 LangChain 或 LlamaIndex,HEARClient 本身就是CallbackHandler的实现,只需要把 client 塞到config.callbacks里,就能自动把框架内部的AgentAction、AgentFinish映射成 HEAR 事件。我实测过,LangChain 的AgentExecutor与 HEAR 的tool_call_detected在绝大多数场景下能完美对齐,唯一的例外是当 Agent 调用嵌套子 Agent 时,需要手动传context_id,否则子 Agent 的事件会挂到父任务下面。
3.3 关键参数与性能实测
我把 HEAR 接到一个 2 卡 A800 集群上,跑 8 并发 Agent 任务,每任务 4 步工具调用,上下文长度维持在 8K~16K tokens。baseline 是裸 vLLM + 标准 HTTP + SSE 轮询解析。
实测下来三项关键数据:
| 性能指标 | 无 HEAR | 有 HEAR | 变化 |
|---|---|---|---|
| 首 token 平均时延 | 380ms | 392ms | +3.2% |
| 完整 Agent 任务时长 | 48s | 41s | -14.6% |
| 每轮 tool_call 后重复 prefill 的 token 量 | 8.2K | 2.1K | -74% |
性能提升来源主要是两个:第一,HEAR 允许引擎侧直接复用 KV cache 继续续写,不用每轮把全部历史重新塞一遍;第二,tool_call 事件让 Harness 可以在生成中间就触发工具执行,两个工具并行的场景能快 20%。
代价也很明确:多一条 WebSocket 长连接、多一组心跳流,在压测到 64 并发时,事件通道占用的带宽大概在 19MB/s 左右,属于可以接受的量级。但如果你跑的是小显存卡,又要省带宽,建议把heartbeat_interval_ms调到 5000,并把大 payload 的progress事件改成只在step维度上报,别逐 token 报。
4. 常见问题与排查技巧实录
4.1 事件通道连接正常,但消息偶尔解析失败
这个是我遇到概率最高的怪问题。现象是:WebSocket 连得上,事件也在发,但 Python 侧json.loads报错,或者 protobufParseFromString返回 false。排查后定位到根因:事件通道里混了两类帧——文本帧和二进制帧。vLLM 后端如果同时用websocket.send(bytes)发 protobuf,又用websocket.send(str)发 JSON 日志,客户端在同一个入站处理器里没有判断帧类型,就会出现解析错乱。
解决办法很简单:客户端先读opcode,OPCODE_BINARY直接走 protobuf 分支,OPCODE_TEXT走 JSON 分支。如果你用的 gRPC stream,不存在这个问题,但 WebSocket 务必做帧类型判别。
另外还有一个细节:事件里的agent_context_id字段,如果包含 Unicode emoji 或特殊符号,在 JSON 序列化时会变成\uXXXX,部分客户端框架会擅自 decode 或 encoding 不一致导致长度不匹配。我后来干脆规定context_id只能是[a-zA-Z0-9_-],从源头杜绝编码问题。
4.2 引擎高负载时,Heartbeat 总是超时
Health Channel 的目的是健康检查,但它在引擎负载高时容易成为“最后一根稻草”。我遇到过引擎 prefill 占满 GPU,heartbeat 线程调度不到,导致 2 秒间隔变成 8 秒才发一次。Harness 侧按照固定超时判断引擎不可用,于是大量请求被重新路由到别的节点,反而引起雪崩。
我的解法是给 heartbeat 设置动态超时:Harness 记录最近 10 次心跳的平均间隔,如果当前间隔小于平均值的 3 倍,就不切换节点;只有连续 5 次超过平均值 3 倍才标记degraded。同时引擎侧把 heartbeat 发到独立的轻量线程,避免和调度线程竞争。
亲测一个更简单的小技巧:health 通道改用 UDP 包发送。心跳本来就不要求可靠传输,丢一两个包没关系,UDP 几乎不占 CPU 和带宽,我压测时把它降到 1 秒间隔都没压力。需要可靠状态查询的客户端,再单独走 gRPC 调用GetHealthSnapshot。
4.3 和 SSE 流式输出共存时,事件顺序会乱
一个很容易忽略的坑:你的 Harness 既用 HEAR 事件,又用原本 SSE 拿最终结果文本,两边都会收到turn_end信息。如果同时消费,可能会把同一条 turn 的文本事件和元数据事件重复处理,造成业务计数翻倍。
我的经验是:确定一个「事实来源」——要么完全依赖 HEAR 的事件通道拿文本和结束标记,要么完全依赖原来的 SSE。建议用 HEAR 的turn_end事件作为主来源,SSE 关掉或者只用来做兜底日志。原因是 HEAR 的turn_end里带sequence_finish_reason字段,能区分output_token_len、stop_token、tool_call三类原因,这是 SSE 文本末尾解析无法稳定做到的。
另外,如果你保留了 SSE 流,必须确认实验环境中 vLLM 的输出 token 是否会把 HEAR 事件消息一起采样出去。不会,因为事件走的是独立通道,和采样器完全隔离。但我曾在某个 fork 版本中发现 streamer 没有过滤系统 prompt,导致事件串进输出流,排查了整整两天。建议部署后先把两种通道录下来对比一下文本部分,确认没有串线。
4.4 替换推理引擎时协议兼容性
团队换推理引擎很常见,比如从 TGI 换到 TensorRT-LLM。HEAR 的协议本身是中立设计,但不同引擎的“工具调用检测”口径不一致。TGI 默认靠stop_sequences触发,TensorRT-LLM 内置function_call解析器,vLLM 则需要 prompt 模板对齐。
我在切换引擎时踩了大坑:从 vLLM 切到 Triton,发现tool_call_detected事件频繁误触发。原因是 Triton 的function call后处理会把模型输出的普通 JSON 片段误判成工具调用。解决办法是升级到 HEAR 协议 v2,在事件里加confidence字段——当引擎没有足够把握时,标记possible,Harness 拿它当参考而不是逐字无脑执行。
最后还有个老生常谈但必须写下来的建议:HEAR 协议务必有自己的版本协商阶段。客户端连接事件通道后第一件事发heart_beat_intro,带上protocol_version: [1, 2],服务端返回selected_version: 2。否则你升级引擎后旧客户端会静默连不上,找原因找到通宵。
5. 一些选型条件和落地建议
如果你问我什么样的团队应该上 HEAR,我的回答分三种情况。
第一种,你的 Agent 只是单轮、短上下文、工具调用就一两次,那么引入 HEAR 属于过度设计,直接用 SSE 解析够了。第二种,你的 Agent 是多轮工具调用、上下文很长且需要前置缓存复用,但并发量没有上来,可以先只上 Event Channel,用 WebSocket 串事件,不加 Health Channel。第三种,就是你正在搭独立的 Agentic LLM Serving 平台,希望把编排层和推理层解耦成可独立扩缩容的微服务,那就全套上,别将就。
部署顺序也有讲究。我复盘自己当初的顺序,踩了太多弯路。正确姿势是:
- 先在 vLLM 侧接入 HEAR,用自带的
hear_cli在命令行发单条请求,确认事件通道能收到turn_end。 - 再到 LangChain 里接回调,跑通单 Agent 的一轮工具调用。
- 确认
context_truncation事件能正确上报后,再考虑多 worker 并发和健康通道路由。 - 全部打通后,再把原 SSE 代码停掉,彻底切换到 HEAR 事件流。
为什么这么排?因为每一步验证都有独立的信号,Log 里每一步都有明确的输出。如果刚开始就一把梭接进生产 load balancer,报错时你不知道是 vLLM 插件 bug 还是 Harness 侧时序错误,排查成本会几何级上升。
关于性能调优,我给个保守的建议:Event Channel 的progress事件,默认只上报“每生成完一个完整 thought/action”级别,别全局开成 token 级。token 级日志只在 debug 时开,线上开着会白白吃掉大约 0.3% 的 GPU 时间片去构造事件结构体。我实测 token 级事件生成开销是 step 级的 5 倍,收益却只有拍错时多两条数据,性价比不高。
还有一点想特别提醒:别把 HEAR 事件通道当分布式 trace 用。HEAR 解决的是 Harness 和 Engine 之间的“实时默契”,不是“事后审计”。如果你需要追溯一个 Agent 任务为什么这样走,更好的选择是把 HEAR 事件落盘到轻量队列(比如 Kafka 或者 NATS),再和 LLM 请求日志做 join。我在生产里就是这么处理的,用agent_context_id作为 join key,把消息排队等待、prefill 耗时、工具调用间隔、最终 completion 原因,全部拼接成一个agent_trace_record,存到 S3 方便后续调 prompt 和分析延迟。这比把所有事件直接灌进 APM 更灵活,成本也低不少。
最后分享一个挺隐蔽的细节。HEAR 的命名不是随意来的——当时团队起名字时我们开玩笑说,这协议就是要让 Harness 和 Engine 互相“听到”对方,做一个 Hear-enabled 的 serving pipeline。后来这个英文缩写被大家念成“hear”,反而传播开了。其实我更喜欢把它理解为“让两个本来只会单向发声的组件,变成能聆听对方呼吸的队友”。在 Agent 场景里,这种“呼吸感”就是系统稳定性的基石。如果你也在给自己的 Agent 栈做类似改造,记住一个原则:不要试图一次性把三个通道全做完,先从事件通道开始,让两端有一根可靠的脉搏线,剩下的自然会跟上。