1. 项目概述:这不是课程笔记,而是一份AI Agent工程化落地的实操手记
“李宏毅课程笔记|Harness Engineering:如何驾驭 AI Agent?”——这个标题里藏着三个关键信号:李宏毅代表的是扎实、系统、不玩虚的AI教学风格;课程笔记暗示内容不是空泛理论,而是从真实学习过程里长出来的结构化沉淀;而最核心的“Harness Engineering”,直译是“驾驭工程”,但绝非字面意义的“控制”或“约束”,它指向一个正在快速成型的新工程范式:把AI Agent当作一个可设计、可测量、可调试、可迭代的工程对象来对待。我带过不少刚接触Agent开发的开发者,他们常卡在同一个地方:写完一个调用LLM的函数,加了几个工具,再套个ReAct循环,就以为做出了Agent。结果一上线,任务失败率高、响应时间飘忽、错误归因困难、用户反馈模糊——根本没法进生产环境。这恰恰暴露了当前最大的认知断层:我们还在用写脚本的思维写Agent,却忘了它本质是一个多模块协同、状态持续演进、外部环境强耦合的动态系统。Harness Engineering要解决的,就是这个断层。它不教你怎么调API,而是教你怎么给Agent装上仪表盘、刹车片、故障诊断仪和升级通道。比如,当一个电商客服Agent连续三次把“退货政策”答成“换货流程”,传统做法是改prompt;Harness Engineering的做法是先检查它的记忆衰减曲线是否异常、工具调用链路中是否有未捕获的超时抖动、知识库检索模块的向量相似度阈值是否被近期数据漂移拉低。这些细节,不会出现在任何一门基础课里,但却是你真正能把Agent用起来、用稳、用久的底层能力。这篇文章,就是我把过去一年在多个实际项目中打磨出的Harness Engineering方法论,拆解成可复现、可验证、可迁移的具体动作。它适合三类人:刚学完LangChain/LlamaIndex想进阶的实践者、正为Agent线上稳定性头疼的工程师、以及想系统理解Agent不再只是“大模型+工具”的技术决策者。全文没有一行代码是为演示而写,每一行都来自某次线上告警后的回溯修复。
2. 核心思路拆解:为什么必须抛弃“Prompt即全部”的旧范式?
2.1 从“单次推理”到“状态化会话流”的范式迁移
早期的AI应用开发,本质上是在做“单次推理优化”。输入一个问题,调整prompt,让模型输出更准的答案。这种模式在问答、摘要等静态任务中有效,但一旦进入Agent场景,立刻失效。原因很简单:Agent的核心价值在于跨步骤、跨工具、跨时间的上下文维持与决策接力。一个订机票的Agent,需要先查航班,再比价格,再确认用户偏好,最后调支付接口——这四个步骤之间,状态必须可靠传递。而传统prompt工程只管“当前这一步怎么写”,对“上一步的输出如何影响下一步的输入”缺乏建模能力。我参与过一个旅游规划Agent的重构,原版本所有状态都靠拼接字符串塞进prompt,结果当用户中途插入一句“算了,改成去上海”,整个上下文就乱了:航班查询结果被覆盖,酒店偏好丢失,甚至支付接口被意外触发。问题根源不在模型,而在状态管理完全依赖LLM的文本记忆能力,而这是不可靠、不可测、不可调试的。Harness Engineering的第一步,就是强制剥离状态。我们把所有中间状态(如已查航班列表、用户明确排除的城市、预算上限)存入独立的结构化状态存储(初期用内存Dict,后期切Redis),每一步Agent执行前,先从这里读取最新状态,执行后,再把新状态写回去。这样,prompt只负责“当前决策逻辑”,状态只负责“事实记录”,二者解耦。实测下来,任务成功率从68%提升到92%,更重要的是,当失败发生时,我们可以直接dump出完整状态快照,而不是对着几千字的prompt日志猜谜。
2.2 “可观察性”不是附加功能,而是Agent的呼吸系统
很多团队把监控当成上线后的补救措施,但对Agent而言,可观测性必须前置到设计阶段。为什么?因为Agent的行为具有强随机性、弱确定性、高延迟敏感性。一次API调用慢了200ms,可能让整个ReAct循环卡死;一个工具返回了格式错乱的JSON,可能让后续所有步骤解析失败。如果你没有在第一步就埋好观测点,等到用户投诉“为什么一直转圈”,你连问题发生在哪一层都不知道。我们定义Agent的“呼吸系统”包含三个必埋点:输入层观测(记录原始用户query、时间戳、会话ID)、决策层观测(记录LLM生成的thought/action序列、token消耗、生成耗时)、执行层观测(记录每个工具调用的入参、出参、耗时、错误码)。这些数据不求实时大屏,但必须能按会话ID一键追溯。有一次,我们发现某个金融咨询Agent在下午3点后失败率陡增,排查数小时无果,最后通过输入层观测发现:那个时段大量用户query里混入了特殊符号“①②③”,而我们的清洗模块恰好漏掉了Unicode编号字符,导致后续所有解析失败。这个bug如果没埋输入观测,根本无法定位。所以,Harness Engineering里,写第一个Agent之前,先写好观测SDK,这和写main函数前先初始化logger一样自然。
2.3 工具链不是“插件集合”,而是有拓扑关系的微服务网络
新手常把工具(Tool)理解为一个个孤立的函数,比如get_weather()、search_web()。但在真实Agent中,工具之间存在严格的依赖拓扑与调用契约。例如,一个房产Agent必须先调用“区域筛选工具”拿到小区列表,才能把列表ID传给“房价查询工具”;如果跳过第一步直接查房价,工具必然报错。Harness Engineering要求我们像设计微服务架构一样设计工具链:定义每个工具的输入Schema(必须是JSON Schema,不能是模糊描述)、输出Schema(明确字段名、类型、是否必填)、失败语义(HTTP 404是“数据不存在”,500是“服务不可用”,需不同重试策略)。我们曾用OpenAPI规范为所有内部工具生成统一描述文件,再用这个文件自动生成Agent的tool description。好处立竿见影:当“天气工具”的API变更(比如把temperature字段改成temp_c),只要更新OpenAPI文件,Agent的tool description自动同步,无需手动改prompt。更关键的是,它让我们能做工具链健康度分析:统计每个工具的平均调用耗时、失败率、参数分布,发现“地图坐标转换工具”在高并发下P99耗时飙升,立刻推动后端优化,而不是等Agent整体变慢才感知。工具链不再是黑盒拼图,而是一张可度量、可治理的服务拓扑图。
3. 关键环节实现:从零搭建一个可驾驭的Agent骨架
3.1 状态管理模块:用“版本化快照”替代脆弱的文本拼接
状态管理是Harness Engineering的基石,但实现上极易陷入两个极端:要么过度设计,搞出一套复杂的状态机框架;要么过度简陋,用全局变量硬扛。我们采用的是轻量级版本化快照方案,核心就三点:状态结构化、快照原子化、版本可追溯。
首先,定义状态基类:
from dataclasses import dataclass, asdict from typing import Optional, Dict, Any import json import time @dataclass class AgentState: session_id: str step_count: int = 0 last_updated: float = 0.0 # 业务状态字段,按需扩展 flight_options: list = None user_preferences: dict = None budget_limit: float = 0.0 def __post_init__(self): if self.flight_options is None: self.flight_options = [] if self.user_preferences is None: self.user_preferences = {} self.last_updated = time.time() def to_dict(self) -> Dict[str, Any]: return asdict(self) def to_json(self) -> str: return json.dumps(self.to_dict(), ensure_ascii=False)这个基类强制所有状态字段显式声明,避免运行时动态赋值导致的不可控。__post_init__确保默认值安全初始化。
其次,快照必须原子化。我们不用数据库事务,而用内存快照+异步持久化:
class StateManager: _cache: Dict[str, AgentState] = {} @classmethod def get_state(cls, session_id: str) -> AgentState: if session_id not in cls._cache: # 从持久层加载(如Redis) raw = redis_client.get(f"state:{session_id}") if raw: data = json.loads(raw) cls._cache[session_id] = AgentState(**data) else: cls._cache[session_id] = AgentState(session_id=session_id) return cls._cache[session_id] @classmethod def update_state(cls, state: AgentState): # 1. 先存内存(保证当前请求可见) cls._cache[state.session_id] = state # 2. 异步写持久层(避免阻塞主流程) asyncio.create_task(cls._async_persist(state)) @classmethod async def _async_persist(cls, state: AgentState): # 使用Redis的SET命令,带过期时间 redis_client.setex( f"state:{state.session_id}", 3600, # 1小时过期,防内存泄漏 state.to_json() )这里的关键是update_state不等待持久化完成,因为Agent的实时性要求极高。我们接受短暂的“内存-持久层不一致”,但通过过期时间兜底。实测在QPS 200的压测下,平均延迟增加不到3ms。
最后,版本可追溯。每次update_state时,我们额外记录一个版本号:
# 在AgentState中增加 @dataclass class AgentState: # ... 其他字段 version: int = 0 def bump_version(self): self.version += 1 self.last_updated = time.time() # 更新时 state.bump_version() StateManager.update_state(state)这个version字段看似简单,却支撑了关键能力:当需要回滚时,我们能按version拉取历史快照;当做A/B测试时,可以按version分组统计不同状态版本下的成功率。它让状态从“当前值”变成了“时间序列”。
提示:不要在状态里存大对象(如原始图片base64、长文本日志)。状态只存“指针”和“元数据”。比如,用户上传的合同图片,状态里只存S3 URL和文件哈希,具体内容由专门的文档服务管理。否则状态体积膨胀会直接拖垮Redis。
3.2 观测埋点模块:用“结构化日志”替代杂乱print
观测不是越多越好,而是要精准、低成本、可关联。我们摒弃了所有print和logger.info,统一使用结构化日志事件(Structured Log Event),每个事件必须包含三个核心字段:event_type(标识事件类型)、session_id(用于全链路追踪)、timestamp(毫秒级时间戳)。
定义日志事件基类:
from datetime import datetime import time @dataclass class LogEvent: event_type: str # "input_received", "llm_thought", "tool_call_start" session_id: str timestamp: float = 0.0 # 业务相关字段,按需扩展 query: str = "" thought: str = "" action: str = "" tool_name: str = "" tool_input: dict = None tool_output: dict = None error: str = "" def __post_init__(self): if self.timestamp == 0.0: self.timestamp = time.time() if self.tool_input is None: self.tool_input = {} if self.tool_output is None: self.tool_output = {} def to_dict(self) -> dict: data = asdict(self) # 格式化时间戳为ISO字符串,便于日志系统解析 data["timestamp_iso"] = datetime.fromtimestamp(self.timestamp).isoformat() return data埋点位置严格遵循Agent生命周期:
# 在Agent入口处 def handle_user_query(session_id: str, query: str): # 1. 输入观测 log_event = LogEvent( event_type="input_received", session_id=session_id, query=query ) logger.info(log_event.to_dict()) # 发送到日志收集服务 # 2. 获取并更新状态 state = StateManager.get_state(session_id) state.step_count += 1 # 3. LLM推理前观测 log_event = LogEvent( event_type="llm_thought_start", session_id=session_id, query=query ) logger.info(log_event.to_dict()) # 执行LLM调用... thought, action = llm.invoke(prompt_template.format(...)) # 4. LLM推理后观测 log_event = LogEvent( event_type="llm_thought_end", session_id=session_id, thought=thought, action=action, # 记录token消耗(需LLM SDK支持) token_usage={"prompt": 120, "completion": 45} ) logger.info(log_event.to_dict()) # 5. 工具调用观测(在工具执行器内) try: result = tool_executor.execute(action, tool_input) log_event = LogEvent( event_type="tool_call_success", session_id=session_id, tool_name=action, tool_input=tool_input, tool_output=result ) except Exception as e: log_event = LogEvent( event_type="tool_call_error", session_id=session_id, tool_name=action, tool_input=tool_input, error=str(e) ) logger.info(log_event.to_dict())这套埋点的关键在于事件类型标准化。我们定义了12种核心事件类型,覆盖从输入到输出的全路径。运维同学只需在Kibana里输入event_type: "tool_call_error",就能看到所有工具失败,再加tool_name: "weather_api",就能聚焦到天气服务问题。没有复杂的日志解析规则,只有清晰的字段过滤。
注意:
logger.info()调用必须是非阻塞的。我们用的是Python的concurrent.futures.ThreadPoolExecutor包装的日志发送器,确保即使日志服务暂时不可用,也不会拖慢Agent主流程。实测在日志服务宕机时,Agent P99延迟波动小于5ms。
3.3 工具链治理模块:用“契约先行”驱动开发与协作
工具链治理的目标是让每个工具像乐高积木一样,能被任意Agent安全、可靠地组合。这要求我们放弃“先写工具,再写Agent”的顺序,改为契约先行(Contract-First):先用OpenAPI 3.0规范定义工具接口,再生成代码和文档。
以一个“股票行情查询工具”为例,其OpenAPI YAML片段:
openapi: 3.0.0 info: title: Stock Quote Tool version: 1.0.0 paths: /quote: get: summary: 获取股票实时行情 parameters: - name: symbol in: query required: true schema: type: string example: "AAPL" - name: exchange in: query required: false schema: type: string enum: ["NASDAQ", "NYSE", "SHSE", "SZSE"] default: "NASDAQ" responses: '200': description: 成功返回行情数据 content: application/json: schema: type: object properties: symbol: type: string price: type: number format: float change_percent: type: number format: float last_updated: type: string format: date-time '404': description: 股票代码不存在 '500': description: 内部服务错误有了这个YAML,我们用openapi-generator自动生成三样东西:
- Python工具类:包含
get_quote(symbol: str, exchange: str = "NASDAQ") -> dict方法,内部已封装HTTP调用、错误处理、重试逻辑; - Agent可用的tool description:将YAML中的summary、parameters、responses自动转为LLM能理解的自然语言描述;
- Postman测试集合:供QA同学直接测试,无需写额外脚本。
这个流程带来的改变是质的:当后端修改了API(比如新增currency参数),只需更新YAML,重新生成,所有下游(Agent、测试、文档)自动同步。我们曾因此将一次API变更的交付周期从3天缩短到2小时。
更进一步,我们用这个YAML构建了工具链健康看板。每天凌晨,一个定时任务会遍历所有工具的OpenAPI文件,调用其/health端点(每个工具必须实现),并将结果存入Prometheus:
| 指标名 | 类型 | 说明 |
|---|---|---|
tool_health_status{tool="stock_quote",env="prod"} | Gauge | 1=健康,0=异常 |
tool_latency_ms{tool="stock_quote",quantile="0.95"} | Histogram | P95延迟(毫秒) |
tool_error_rate{tool="stock_quote"} | Rate | 每分钟错误率 |
运维同学在Grafana里一眼就能看出哪个工具在拖慢整个Agent集群。这才是真正的“驾驭”,不是靠猜,而是靠数据。
4. 实战问题排查:那些让你半夜爬起来的Agent故障现场
4.1 故障现象:Agent在特定时段批量“失忆”,用户重复提问
现场还原:某教育辅导Agent在每天上午9:00-9:15集中出现“不记得上一轮对话”的问题。用户问“刚才说的公式是什么?”,Agent回答“我不记得之前的对话”。日志显示,input_received事件正常,但llm_thought_start事件里的session_id和上一轮不一致。
排查路径:
- 首先检查
session_id生成逻辑:前端传来的session_id是JWT token的一部分,解码后发现,token有效期设为15分钟,且9:00整点签发。这意味着所有在9:00签发的token,都在9:15过期,前端自动刷新token,生成新session_id。 - 再查状态管理:
StateManager.get_state()在session_id不存在时,会创建新AgentState,导致“失忆”。 - 根本原因:会话生命周期与Token生命周期未对齐。Token是认证凭证,
session_id是状态标识,二者不该强绑定。
解决方案:
- 前端:
session_id改为独立生成的UUID,与JWT token解耦。JWT只用于认证,不携带session_id。 - 后端:
StateManager.get_state()增加fallback逻辑:当新session_id不存在时,尝试从请求头中读取X-Previous-Session-ID(前端在token刷新时主动带上),并迁移旧状态。 - 加入熔断:如果1小时内同一用户触发超过5次状态迁移,自动标记为“可疑会话”,降级为无状态模式,并告警。
实操心得:永远不要假设
session_id是稳定不变的。在分布式系统中,它可能因负载均衡切换、客户端重启、网络抖动而变化。Harness Engineering要求我们为session_id设计“柔性一致性”:允许短暂不一致,但必须有快速收敛机制。我们后来在所有Agent入口加了一行日志:logger.debug(f"Session continuity: {old_sid} -> {new_sid}"),专门监控迁移频率。
4.2 故障现象:Agent响应时间从2s突增至15s,但CPU/内存无异常
现场还原:某客服Agent在某次发布后,P95响应时间从2s飙升至15s,监控显示服务器CPU、内存、网络带宽均正常。日志里llm_thought_end和tool_call_start之间的时间差巨大。
排查路径:
- 查
llm_thought_end日志,发现token_usage字段显示completion tokens高达12000,远超正常值(通常<500)。说明LLM生成了超长文本。 - 追溯
llm_thought_start日志,发现query字段里混入了大量HTML标签(<div>...</div>),这是前端富文本编辑器未过滤导致的。 - 分析LLM行为:当prompt里混入大量无意义HTML,模型会尝试“理解”这些标签,生成冗长的、试图解释HTML结构的thought,导致token爆炸和生成延迟。
解决方案:
- 前置清洗:在
handle_user_query入口,增加HTML标签过滤:import re def clean_html(text: str) -> str: # 移除所有HTML标签,保留纯文本 clean = re.compile('<.*?>') return re.sub(clean, '', text) query = clean_html(query) - LLM层防护:在prompt template中加入硬性约束:“你的thought必须在200字以内,禁止生成任何HTML、XML、JSON格式内容,只用纯中文回答。”
- 增加token预警:在
llm_thought_end日志中,如果completion_tokens > 1000,自动触发告警,并记录完整prompt供人工审计。
注意:不要依赖LLM自己遵守长度限制。我们测试过,在prompt里写“请用100字回答”,模型仍有30%概率超限。必须在代码层做硬拦截。后来我们加了一条规则:LLM调用前,预估prompt长度(用tiktoken),如果预估completion tokens > 1000,则自动截断用户输入的最后200字符,并在响应中追加提示:“您的问题较长,已做精简处理。”
4.3 故障现象:Agent在处理多轮复杂任务时,工具调用顺序混乱,出现“先支付后选商品”
现场还原:某电商Agent在用户说“帮我买iPhone 15,预算5000,顺丰发货”时,有时会跳过商品搜索,直接调用支付工具,导致支付失败。
排查路径:
- 查
llm_thought_end日志,发现thought里写着:“用户要买iPhone 15,我需要先调用支付工具完成下单。”——这明显违背了ReAct逻辑。 - 检查ReAct循环代码,发现
action解析逻辑有缺陷:当LLM输出Action: pay时,代码只匹配开头的Action:,但忽略了Action Input:后面的内容。而这次LLM输出是:
代码错误地把Thought: 用户要买iPhone 15,我需要先调用支付工具完成下单。 Action: pay Action Input: {"amount": 5000}Thought行里的“支付工具”当成了Action,导致误触发。 - 根本原因:LLM输出解析过于脆弱,未做格式校验。
解决方案:
- 重构解析逻辑,强制要求LLM输出严格遵循格式:
Thought: [你的思考过程] Action: [工具名] Action Input: [JSON格式输入] Observation: [工具返回结果] - 在代码层做三重校验:
- 正则匹配:必须同时存在
Thought:、Action:、Action Input:三行; - JSON校验:
Action Input后的字符串必须是合法JSON; - 工具存在性校验:
Action字段的值必须在预定义工具列表中。
- 正则匹配:必须同时存在
- 如果任一校验失败,不执行工具,而是生成一条固定回复:“我暂时无法理解您的请求,请换一种方式描述。”并记录
parsing_failed事件。
实操心得:永远不要相信LLM的输出格式。我们后来在所有Agent部署时,都开启了一个“格式校验开关”,开关打开时,任何格式错误都会被拦截并告警;开关关闭时,才走宽松解析。这个开关成了我们灰度发布的利器:新版本先开开关跑一周,等格式错误率降到0.1%以下,再关掉开关全量。
5. 工程化进阶:从单体Agent到可编排的Agent工作流
5.1 为什么需要Agent工作流?单体Agent的天花板在哪里?
单体Agent(Monolithic Agent)指所有逻辑(规划、工具调用、记忆、输出)都封装在一个实例里。它简单直接,但存在三个硬性天花板:
第一,职责过载,难以维护。一个电商Agent既要懂商品搜索,又要懂库存校验,还要懂支付风控,代码行数轻松破万。当支付接口升级,你得通读整个Agent代码,生怕改错一行就影响搜索功能。
第二,资源争抢,性能瓶颈。所有工具调用都挤在同一个线程/进程里。当“图像识别工具”在做CPU密集型计算时,“天气查询工具”的HTTP请求只能排队,导致整体响应变慢。
第三,复用困难,成本高昂。你在电商Agent里写的“地址解析工具”,无法直接用在物流Agent里,因为两者状态结构、错误处理逻辑完全不同。
Harness Engineering的进阶,就是打破单体,走向Agent工作流(Agent Workflow):把一个复杂任务拆解为多个专业Agent,每个Agent只专注一件事,它们之间通过明确定义的输入/输出契约协作。这就像工厂流水线,每个工位只做一道工序。
5.2 工作流编排器:用DAG图定义Agent协作关系
我们采用**有向无环图(DAG)**作为工作流的抽象模型。每个节点是一个Agent,每条边代表数据流向。以“智能旅行规划”为例,其DAG如下:
[用户Query] ↓ [意图识别Agent] → 判断是"订机票"、"订酒店"还是"行程规划" ↓ (if "行程规划") [城市筛选Agent] → 根据预算、季节、用户偏好筛选候选城市 ↓ [景点推荐Agent] → 为每个候选城市推荐TOP3景点 ↓ [交通规划Agent] → 计算城市间交通方式及时长 ↓ [最终整合Agent] → 生成图文并茂的行程单实现这个DAG,我们不造轮子,而是基于成熟的Apache Airflow改造。Airflow原生支持DAG定义、任务调度、失败重试、依赖管理,我们只需做两件事:
封装Agent为Operator:
from airflow import DAG from airflow.operators.python import PythonOperator from datetime import datetime def run_city_filter(**context): # 从XCom获取上游输出 user_query = context['ti'].xcom_pull(task_ids='intent_recognition', key='query') # 调用城市筛选Agent result = city_filter_agent.run(user_query) # 将结果推送到XCom,供下游使用 context['ti'].xcom_push(key='cities', value=result) with DAG('travel_planner', start_date=datetime(2024,1,1)) as dag: intent_task = PythonOperator( task_id='intent_recognition', python_callable=run_intent_recognition ) city_task = PythonOperator( task_id='city_filter', python_callable=run_city_filter, # 显式声明依赖 depends_on_past=False, trigger_rule='all_success' ) # 建立DAG边 intent_task >> city_task增强XCom传输能力:Airflow默认XCom只传小数据(<48KB)。我们替换了XCom backend,用Redis存储大对象,只在XCom里存Redis Key,完美支持图片、PDF等二进制结果的传递。
这个方案的好处是:所有工作流逻辑可视化、可版本化、可回滚。Airflow UI里,你能看到实时DAG图,点击任一节点,就能看到它的输入、输出、日志、耗时。当“景点推荐Agent”升级,你只需修改run_attraction_recommend函数,DAG其他部分完全不受影响。
5.3 工作流治理:如何防止Agent工作流变成“黑盒迷宫”?
工作流带来灵活性,也带来新挑战:当DAG有20个节点时,一个失败可能引发连锁反应,而你根本不知道源头在哪。Harness Engineering要求我们为工作流建立三层治理:
第一层:节点级SLA契约。每个Agent节点必须声明自己的SLA:
| Agent节点 | P95延迟 | 失败率 | 输入大小限制 | 输出大小限制 |
|---|---|---|---|---|
| 意图识别 | <800ms | <0.5% | <2KB | <500B |
| 城市筛选 | <1.2s | <1.0% | <5KB | <2KB |
| 景点推荐 | <2.5s | <2.0% | <10KB | <5KB |
工作流编排器在调度前,会校验整个DAG的SLA叠加是否满足终端用户要求(如“全程<5s”)。如果不满足,自动拒绝调度,并返回降级方案。
第二层:链路级可观测性。我们在每个节点的Operator里,注入统一的观测SDK:
def run_with_observability(func): def wrapper(**context): # 记录节点开始 log_event = LogEvent( event_type="workflow_node_start", session_id=context['dag_run'].conf.get('session_id', 'unknown'), node_id=context['task'].task_id, input_size=len(str(context['ti'].xcom_pull())) ) logger.info(log_event.to_dict()) try: result = func(**context) # 记录节点成功 log_event = LogEvent( event_type="workflow_node_success", session_id=context['dag_run'].conf.get('session_id', 'unknown'), node_id=context['task'].task_id, output_size=len(str(result)), duration_ms=(time.time() - start_time) * 1000 ) logger.info(log_event.to_dict()) return result except Exception as e: # 记录节点失败 log_event = LogEvent( event_type="workflow_node_error", session_id=context['dag_run'].conf.get('session_id', 'unknown'), node_id=context['task'].task_id, error=str(e) ) logger.error(log_event.to_dict()) raise return wrapper这样,一个workflow_node_error事件,就能精准定位到是哪个节点、哪个会话、哪个输入出了问题。
第三层:工作流级熔断。当某个节点在5分钟内失败率超过10%,工作流编排器自动触发熔断:
- 暂停该节点的所有调度;
- 将流量路由到备用Agent(如用规则引擎Agent替代LLM Agent);
- 发送告警,并附上最近10次失败的完整日志链接。
这套治理机制,让工作流不再是“一堆Agent的随意组合”,而是一个有纪律、有保障、可信赖的工程系统。
6. 总结:Harness Engineering的本质,是把AI Agent从“艺术品”变成“工业品”
写到这里,我想起去年在某次技术分享会上,一位资深架构师问我:“你们做了这么多工程化,是不是把AI的‘灵性’给磨没了?”我当时没直接回答,而是反问他:“汽车刚发明时,司机要自己调火花塞、换齿轮油、听发动机声音判断故障。今天的司机,只需要踩油门、打方向。我们失去的是‘灵性’吗?不,我们得到的是可预测、可复制、可规模化的出行能力。”AI Agent也一样。Harness Engineering不是要消灭LLM的创造力,而是要把它关进一个坚固、透明、可控的“工程容器”里。这个容器里,状态是可追溯的,行为是可观测的,工具是可治理的,工作流是可编排的。当你能用kubectl get pods查看Agent实例,用curl -X POST /workflow/dag?session_id=xxx触发一次完整流程,用SELECT * FROM agent_errors WHERE node='payment' AND time > now() - 1h查故障,你就知道,AI Agent已经走出了实验室,真正踏上了工业化生产的轨道。我个人在实际操作中的体会是:别急着堆功能,先搭好Harness。那套状态管理、观测埋点、工具契约的代码,可能只占你总代码量的15%,但它决定了剩下85%的功能,能不能活过上线第一天。最后再分享一个小技巧:每周五下午,留出30分钟,专门做“Harness健康检查”。打开你的日志系统,搜event_type: "tool_call_error",挑出Top 3的错误,花10分钟看能否用一行代码加固(比如加个空值判断、加个超时设置)。坚持三个月,你会惊讶于Agent的稳定性提升。这,就是Harness Engineering最朴素的胜利。