HEAR协议:解决Agentic LLM Serving编排与推理引擎双向通信 在聊 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 还是 ProtobufHEAR 最初我用了 JSON因为调试方便——每个消息都是一个自描述的 JSON 对象WebSocket 直接丢过来json.loads就行。后来并发一上来JSON 的解析开销能占到事件通道 CPU 的 4%而且 JSON 没有 schema 约束上游一个字段拼错下游拿到None只能靠 if 判断兜底。于是我把 Event Channel 和 Health Channel 改成ProtobufControl 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 栈上部署 HEAR3.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_urlhttp://engine:8000, event_urlws://engine:8001/ws, health_urlgrpc://engine:8002, context_idagent_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 或 LlamaIndexHEARClient 本身就是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 平均时延380ms392ms3.2%完整 Agent 任务时长48s41s-14.6%每轮 tool_call 后重复 prefill 的 token 量8.2K2.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 日志客户端在同一个入站处理器里没有判断帧类型就会出现解析错乱。解决办法很简单客户端先读opcodeOPCODE_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 占满 GPUheartbeat 线程调度不到导致 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字段——当引擎没有足够把握时标记possibleHarness 拿它当参考而不是逐字无脑执行。最后还有个老生常谈但必须写下来的建议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 栈做类似改造记住一个原则不要试图一次性把三个通道全做完先从事件通道开始让两端有一根可靠的脉搏线剩下的自然会跟上。