生产级Agent Harness实战:基于PI构建稳定可控的任务执行框架
发布时间:2026/9/28 15:02:11 作者:尧图编辑部 阅读量:1,286

“把 Agent 框架拆开”这句话我在不同场合说了不下十次。做 agent 项目做得越久越清楚真正难的不是让模型跑起来而是让 agent 在线上没人盯着的时候也能稳定、安全、可控地完成一整套任务。我最近把线上跑了半年多的 agent 服务做了一次大重构核心工作就是把这层“包壳”单独抽出来用 PI 重新做成了一套生产级 Harness。先说结论免得你绕弯路框架解决的是“让 agent 能想”Harness 解决的是“让它能在生产环境里安全、稳定、可控地跑”。PI 在这里是一个轻量级 agent 运行时社区里有人叫它 Personal Intelligence也有人干脆叫 PI runtime它把会话、事件流、工具调用这些基础能力收敛得很干净特别适合在上面自己包一层工程外壳。这篇文章适合正在做 agent 落地的读者也适合那些想摆脱对全家桶框架依赖、自己做执行控制的团队。下面直接讲我对 PI 的拆解以及生产级 Harness 的完整搭建过程。1. 为什么框架之外还要有 Harness很多人第一次接触 agent 开发都是从跑通一个 Demo 开始的。模型能回答、工具能调用、上下文能记住就觉得差不多了。但把这些东西放到生产环境你会发现框架给不了你三样东西任务生命周期管理、异常恢复机制、管控与审计能力。这三样恰恰是生产级系统的及格线也正是 Harness 要补上的。1.1 Agent 框架通常不给你的三样东西你可以把大模型框架理解成一台发动机它负责把“意图”转成“行动”负责对话、推理、调用工具。但一辆能上路的车除了发动机还需要底盘、刹车、仪表盘和安全带。Harness 就是这辆车的外壳和控制系统。第一框架通常不帮你管“这个任务现在到底处于什么阶段”。Demo 里一次对话结束就完了但生产环境里一个任务可能要执行几分钟甚至几十分钟中间要调多个工具、可能还要等人审批。如果没有显式的状态机任务一旦卡住你根本不知道它卡在哪个环节。第二框架通常不具备故障恢复能力。模型调用超时、工具返回异常、进程崩溃这些都是生产环境的常态。框架会抛一个异常给你但不会帮你自动重试、把失败任务转入死信队列、或者从检查点恢复。这些逻辑必须由 Harness 来实现。第三框架缺乏管控面。谁在什么时间调用了哪个工具、消耗了多少 token、执行了几次重试这些数据如果没有统一收口出问题的时候你只能对着日志猜。Harness 的作用就是用统一的事件流把这些信息全部沉淀下来。1.2 生产级这个词的及格线我在评审一个 agent 项目能不能上线时只看四件事不丢任务任务进来之后无论进程怎么崩任务本身不能丢。要么完成要么明确失败并进入可处理队列绝不能不明不白消失。能恢复单次模型调用失败不意味着整个任务失败。要有重试策略要有退避算法还要有上下文保留机制。可观测任何一个环节出问题都能通过结构化日志和链路追踪快速定位。不能等到用户投诉了才发现任务挂了。可干预涉及高风险操作下单、删除、转账时系统能把任务挂起等待人工审批后再继续。这四条就是“生产级”的底线。很多团队 Demo 跑得飞起上了生产就出事基本都是在这些地方欠了债。1.3 为什么选 PI 而不是自研引擎做 Harness 之前我认真考虑过两个方案一个是直接用 LangChain、LlamaIndex 这类全家桶框架在上面做二次封装另一个是自己写一个运行时。最后选了 PI原因是它在两者之间找到了一个比较舒服的位置。PI 给我的核心价值是 session 管理和事件流抽象。它把一次完整的 agent 执行过程建模成 session、turn、event 三个层次session 是完整任务turn 是一次模型交互event 是流式输出过程中的每一个事件。这个模型足够简单但也有足够的表现力——我可以基于它去构建状态机、钩子函数和事件订阅而不用被框架内部的重型抽象绑架。换句话说PI 给我的是“可控的零件”引擎内部的组装工作留给我自己这样我就有空间去建一套真正贴合业务的 Harness。2. PI Harness 整体设计与分层思路Harness 不是某一个单独的模块它是一整套分层结构。我设计时的原则是上层不直接碰模型下层不直接对业务暴露接口所有交互都通过统一事件流来驱动。这样每一层都可以独立替换、独立测试。2.1 先拆 PI 运行时的工作模型要理解 Harness得先理解 PI 是怎么跑起来的。PI 的工作模型可以概括为三个层次Session会话代表一个完整的任务实例。它持有上下文、状态、元数据是 Harness 生命周期管理的最小单位。Turn轮次一次完整的“用户输入 → 模型推理 → 可能的工具调用 → 模型输出”循环。一个 Session 里可以有多个 Turn。Event事件PI 在执行过程中持续产生的事件流比如 token 增量、工具调用开始、工具调用结束、错误发生等。对 Harness 来说最关键的是事件流。我不需要侵入 PI 内部去改逻辑只需要订阅它的事件然后驱动我自己的状态机。这种设计让 Harness 和运行时之间形成了一种松耦合升级 PI 版本的时候我的业务逻辑基本不用动。2.2 Harness 四层结构我这里把 Harness 整体分成四层从上到下依次是接入层负责接收外部请求做鉴权和限流把请求包装成标准任务放入队列。这一层可以是 HTTP API也可以直接消费消息队列。编排层这是 Harness 的核心负责状态机流转、重试策略、审批流程、配额控制。它不关心模型细节只关心“这个任务现在应该做什么”。执行层持有 PI 运行时实例负责实际驱动模型会话、执行工具调用。工具在这里被包装成标准接口由编排层统一调度。底座层提供存储任务状态、事件日志、缓存分布式锁、幂等、可观测性指标、日志、链路追踪。这个分层最直接的好处是我可以单独扩容执行层也可以单独升级编排策略互不影响。有一次我调整重试参数只是改了编排层的一个配置项执行层完全没动就完成了上线。2.3 三个核心数据结构Harness 的数据结构不需要很复杂但一定要稳定。我最核心的三个数据结构是结构关键字段作用HarnessConfigworker_concurrency, retry_policy, quota_policy, tool_timeoutHarness 的总配置运行时不可变变更走配置中心灰度TaskEnvelopetask_id, session_id, payload, priority, created_at, state统一任务包装无论任务来源是 HTTP 还是队列都转成这个结构AgentContextsession_ref, state, turn_count, token_usage, tool_call_logs任务执行过程中的全量上下文持久化存储支持恢复这三个结构基本覆盖了我的全部需求。TaskEnvelope 保证任务在系统里的流转是统一的AgentContext 保证任何时刻进程崩溃都能重建现场HarnessConfig 保证行为可控可灰度。做生产级 Harness先把这三个东西定死后面写代码轻松一半。3. 生产级 Harness 的核心实现细节设计定完之后真正花时间的是实现细节。下面这几个点每一个都是我在生产环境踩过坑之后才定下来的方案直接给你可复制的写法。3.1 生命周期状态机怎么落地任务生命周期是 Harness 的骨架。我用的是显式状态机而不是散落的 if/else 判断。状态机的好处是非法流转在进入状态之前就能被发现。# harness/state.py import enum class HarnessState(str, enum.Enum): PENDING PENDING RUNNING RUNNING WAITING_INPUT WAITING_INPUT WAITING_APPROVAL WAITING_APPROVAL COMPLETED COMPLETED FAILED FAILED CANCELLED CANCELLED # 合法的状态流转表 TRANSITIONS { HarnessState.PENDING: { HarnessState.RUNNING, HarnessState.CANCELLED, }, HarnessState.RUNNING: { HarnessState.WAITING_INPUT, HarnessState.WAITING_APPROVAL, HarnessState.COMPLETED, HarnessState.FAILED, }, HarnessState.WAITING_INPUT: { HarnessState.RUNNING, HarnessState.CANCELLED, }, HarnessState.WAITING_APPROVAL: { HarnessState.RUNNING, HarnessState.CANCELLED, }, HarnessState.FAILED: { HarnessState.RUNNING, # 允许人工介入后重跑 }, } def validate_transition(current: HarnessState, target: HarnessState) - bool: return target in TRANSITIONS[current]你可能会问为什么 WAITING_APPROVAL 要单独做一个状态而不是挂在 RUNNING 下面因为这两个状态在运维语义上完全不同RUNNING 是正在花钱的阶段模型在调用、工具在执行WAITING_APPROVAL 是已经暂停的阶段不产生成本需要人介入。我一开始把审批状态挂在 RUNNING 里结果超时策略和计费统计全乱套了。拆开之后超时策略可以分别配置RUNNING 阶段严格执行调用超时WAITING_APPROVAL 阶段则允许长期挂起。3.2 超时、重试和熔断的参数怎么定大模型场景的重试比普通 RPC 复杂因为它不只是“重试一次请求”还关系到上下文污染、token 浪费、上游限流。我使用的重试策略参数如下参数推荐值说明首 token 超时15s超过直接重试多半是上游排队总响应超时120s流式场景也必须有总时长上限最大重试次数3 次超过后进入死信队列不无限重试基础退避1s指数退避的初始值最大退避60s防止退避过长拖死任务重试的代码实现可以很简单但有一个关键点重试不能改变上下文。同一轮 Turn 的重试必须使用完全相同的输入并且要保证幂等——工具调用如果有副作用重试前要确认上一次调用真的没生效。# harness/retry.py import random def next_backoff(retry_count: int, base: float 1.0, cap: float 60.0) - float: backoff min(base * (2 ** retry_count), cap) jitter random.uniform(0, backoff * 0.1) return backoff jitter def should_retry(retry_count: int, max_retries: int 3) - bool: return retry_count max_retriesjitter 一定要加。不加 jitter 的话一旦上游模型服务出问题所有 worker 会同时重试形成重试风暴直接把上游打挂。加了随机抖动之后重试请求会自然散开实测效果明显。熔断方面我比较粗暴单 worker 在 5 分钟内如果连续 10 次模型调用超时就触发熔断暂停从队列拉取新任务 30 秒。这个阈值不复杂但配合退避算法之后整个系统的稳定性提升了一个级别。3.3 Human-in-the-loop 怎么插入执行流生产级的 agent 系统基本都绕不开人工审批。比如自动生成内容后要发布、自动下单、自动删除数据这些高风险操作不能完全交给模型决定。我的方案是让 Harness 在工具调用前检查策略命中高风险策略就挂起任务等人审批。流程是这样的编排层解析 PI 发来的工具调用事件先走权限校验和策略判断。如果该工具被标记为“需要审批”Harness 将任务从 RUNNING 状态转到 WAITING_APPROVAL 状态同时持久化当前 AgentContext。系统发送审批通知企业微信、钉钉、邮件都可以看你公司用什么。审批人通过内部管理后台查看待审批任务可以看到即将执行的工具、参数、上下文摘要。审批通过后Harness 恢复 AgentContext从原 checkpoint 继续执行审批拒绝则任务转入 CANCELLED。这里有一个非常容易踩的坑审批中的上下文一定要持久化到数据库不能只放在进程内存里。你想一下任务挂起后如果 worker 刚好发布重启内存里的上下文就全没了审批通过之后根本没法恢复。我一开始就是这个坑后来把所有 WAITING_APPROVAL 状态的任务上下文都写进了数据库才彻底解决。3.4 可观测性、配额与成本控制Agent 系统的成本控制是生产中很现实的问题。模型调用是收钱的一个失控的 agent 循环调用工具可能几分钟就烧掉上千块。我的做法是所有 token 消耗和工具调用都统计到任务级别并且设置硬性配额。每个任务配额固定为配额项默认值超过后行为max_turns30强制终止任务max_prompt_tokens10000触发上下文压缩max_tool_calls50后续工具调用直接拒绝max_total_cost_usd2.0强制终止并通知管理员日志方面我采用 JSON lines 格式每个事件一行。标准字段包括task_id、session_id、turn_id、event_type、model、prompt_tokens、completion_tokens、tool_name、tool_latency_ms、state_from、state_to。这样可以直接喂给日志采集系统做监控和告警也可以拿来做审计回溯。可观测性做得好的标志是一个任务出问题你在监控面板上从 task_id 进去能看到它整个生命周期的完整时间线——什么时候进的队列、模型调用花了多久、哪个工具超时了、重试了几次、最终落在哪个状态。到了这个程度排障基本上就不是靠猜了。4. 从 Demo 到线上部署形态与稳定化Harness 写完之后怎么部署同样有讲究。我见过不少团队把 agent 服务写成单体进程一个进程既接收 HTTP 请求又跑模型调用出问题连是入流量打爆还是模型调用卡死都分不清。4.1 最小可行部署拓扑我的部署形态是“worker 模型”任务先进入队列worker 进程从队列里取任务执行执行结果再写回存储。它带来的直接好处是队列天然提供了削峰填谷能力模型服务变慢时任务会在队列里积压而不会把 HTTP 入口拖死。一个最少可行的拓扑是接入层Nginx 对外提供 API做鉴权和基础限流。队列Redis Stream 做任务队列。选择 Redis Stream 而不是普通 List是因为它支持消费者组、消息确认和 Pending Entries List这些特性天然契合任务系统的可靠性要求。执行层3 到 5 个 worker 进程横向部署每个 worker 同时跑 2 到 4 个 session。存储PostgreSQL 存任务状态和审批上下文Redis 同时承担分布式锁角色。这里要特别注意不要用“长连接直连模型服务”的形态。也就是每个 worker 进程在启动时建立一个到模型服务的长连接然后所有 session 共用。这样一旦网络抖动所有 session 会同时断连整个 worker 直接进入重试风暴。我现在的做法是每次 Turn 都从连接池里取连接用完归还池大小 10 到 20。这样单次连接异常只会影响一个 Turn而不是整个进程。4.2 Tool 与 Skill 的注册管理Tool 和 Skill 是 agent 系统里很容易混淆的两个概念。我用一句话区分Tool 是原子能力Skill 是带流程编排的可复用技能包。Tool 的例子是“查询订单状态”“获取天气”“发送 HTTP 请求”。Skill 的例子是“写周报”——它内部会先读取本周工作记录再调用总结模型生成内容最后按模板格式化这是多个步骤的编排。在 Harness 里Tool 和 Skill 都通过 manifest 文件注册。每个 Tool 声明自己的名字、描述、权限范围、超时时间和输入输出格式# tools/order_query.yaml name: order_query version: v1 description: 根据订单ID查询订单状态 permissions: scope: order:read rate_limit: 20/min timeout: 8s input_schema: type: object properties: order_id: type: string required: - order_idHarness 加载 manifest 时做三层校验格式合法性、权限范围是否超出当前租户所授权限、输入 schema 是否完整。校验通过之后才注册到执行环境。这样做的好处是工具接入变成了写配置文件不需要改代码而且权限边界在接入那一刻就确定了。4.3 灰度发布与版本回滚Harness 本身的升级同样需要流程。我的经验是把 Harness 的配置和代码分开管理。代码升级走镜像灰度比如先发布一个 node 到预发环境跑自动化测试再逐步放量到生产。配置变更走配置中心灰度比如调整重试参数时先在 5% 的任务上生效观察错误率没有变化再推到全量。回滚策略要特别关注状态兼容性。比如你升级了状态机的定义加了一个新状态那旧版本代码是无法识别这个新状态的。所以每次 Harness 代码升级前我都要检查数据库里有没有处于“新状态”的任务如果有要么先把这些任务处理完再升级要么做一个状态迁移脚本。这个检查我写进了发布手册每次发版必看。4.4 稳定性验收清单每次改动上线前我会跑一遍这个清单检查项方法通过标准任务不丢失杀掉 worker 后检查队列中待处理任务重启后任务全部继续执行幂等重试模拟工具调用成功但响应丢失不重复产生副作用超时隔离让单个工具 sleep 超过超时时间只失败当前 Turn不影响其他 session审批恢复挂起任务后重启 worker审批后任务可从 checkpoint 恢复配额生效将 max_turns 调为 1 触发限制任务被强制终止并记录原因流式断连在模型响应中途断网 5 秒触发重试并最终成功限流保护高并发压测接入层超额请求被丢弃worker 不崩日志完整性抽样检查任务时间线每个状态变更都有对应事件日志这套清单执行一次不到 30 分钟但它保住了我很多次上线。稳定性不是靠写代码写出来的是靠一次次模拟故障验出来的。5. 常见问题与排查实录最后这部分是我在实际运营中遇到的真实问题每个都让我改过代码或调整过配置。整理成速查表放最后建议收藏备用。5.1 流式响应中途 malformed 怎么处理这是我最常遇到的问题报错信息类似the response stream was malformed and no response was produced. try again.。一开始我以为是模型服务的问题后来发现很多时候是连接层的问题模型以流式方式返回数据中间某个 chunk 在传输层被截断了尤其是网络代理、网关缓冲设置不当的时候特别容易出现。排查步骤是这样的先看是不是网络层截断。检查网关有没有设置proxy_read_timeout如果超时过短流式响应很容易中间断掉。把超时从 30s 调到 120s 能解决相当一部分问题。再看是不是模型服务监控显示有异常。如果上游自己没问题那就是连接层的锅。最后看是不是代码层错误处理不当。流式读取时如果遇到 EOF 或 JSON 解析失败正确做法是把这个 Turn 标记为可重试而不是直接把任务置为 FAILED。后来我实现了“断流自动重建”机制Harness 捕获到 malformed 错误后保留原始输入上下文重新开启一个 Turn 重试重试次数 1 次。如果重试仍然失败才进入死信队列。这个机制上线后这类错误对线上任务的影响降到了几乎为零。5.2 任务卡在 RUNNING 状态有一次线上收到告警说某个任务执行时间已经超过了一个小时。我进去查状态发现任务一直停在 RUNNING但 worker 日志里根本没有这个任务的执行记录——进程早就不在了但任务状态没被更新。原因是 worker 崩溃时没有“租约过期”机制。worker 从队列里取走任务时任务状态变成了 RUNNING但如果 worker 在任务还没执行完就崩溃这个状态会一直卡在那里。解决方案是给任务加租约机制worker 拿到任务时写入一个租约过期时间比如 5 分钟然后每 30 秒续租一次。如果租约过期且没有续租说明 worker 已经不在了其他 worker 可以接管这个任务。接管时重新记录执行状态并把任务重新置为 RUNNING。5.3 Token 超限、上下文污染和工具输出爆炸模型上下文窗口是有限的但工具可以返回无限大的数据。比如一个查询工具返回了 10 万字的 JSON如果直接把它塞进上下文下一个 Turn 不仅浪费 token还可能让模型产生幻觉。我的做法是给所有工具输出加“体积保险丝”工具输出超过 2000 字符时自动截断并附加“已截断完整数据可调用 get_more 工具获取”的提示。同时每轮 Turn 结束后检查上下文总 token 数超过阈值的部分按“最早的消息优先丢弃”策略压缩但会保留系统提示词和对当前任务最关键的信息。这个策略牺牲了一点点“记忆力”但换来了每个任务的成本可控、响应速度稳定。真实业务里绝大多数任务的关键信息在最近几轮上下文里就够了。5.4 排查速查表现象定位方法处理建议任务成功但用户说没收到结果查任务状态和事件时间线看最后一步回调是否失败检查回调错误处理增加回调失败重试模型响应经常中断查网络层的超时和缓冲配置调大流式超时断流自动重试 1 次任务大量进入死信队列看死信队列里的错误分类按类型聚合统计对 Top 错误逐个解决不要零散处理成本超支按 task_id 查 token 消耗和模型调用次数收紧 max_turns 或 max_total_cost_usd同一工具频繁超时查工具执行日志里的耗时分布优化工具本身或减少该工具的并发配额最后再说一个我反复踩坑后养成的习惯每次上线前我都会人为杀掉 worker、断掉 Redis 连接、模拟模型服务 5 秒超时把线上搞乱一遍再让 Harness 自恢复。Harness 不是写出来的是练出来的。PI 把运行时的复杂性控制得很小你才有精力把外面这层壳做得足够结实。Agent 上线这件事框架选谁其实没那么重要Harness 能不能兜住底才是决定一个 agent 项目能不能长期稳定跑下去的关键。