做 AI Agent 项目做到第五个版本的时候,我终于承认一件事:模型本身不是最大的瓶颈,围绕模型的工程约束才是。标题里那句“构建稳定的 AI Agent”听起来像是网络上随手一搜就有的泛泛之谈,但真把 Agent 丢到生产环境里跑上一周,你才会明白什么叫“稳定是稀缺品”。今天这篇东西,我不打算讲什么大而全的架构蓝图,只想围绕 Harness 工程(给 Agent 套缰绳的工程实践)这个核心,聊聊 Agent 为什么不稳定、怎么让它稳定、以及我在实际项目中踩过的坑和沉淀下来的做法。如果你正在用 Claude Code、LangGraph、FastAPI 这类工具搭自己的 Agent,或者被“Agent 能跑但不敢上生产”折磨过,这篇应该能给你一些能直接落地的参考。
1. 为什么 Agent 总是不稳定:从一次“翻车”说起
先说一件让我彻底改变思路的事。早期我做过一个内部用的数据查询 Agent,模型用的是当时最强的闭源模型,工具链也齐全——能查库、能调接口、能生成报表。Demo 阶段一切完美,但一放到真实业务环境,问题就来了:用户随口一句“上个月华东区的退货率怎么样”,它能正确把 SQL 写出来,却因为表名权限配错,反复重试三次后开始胡编一个数值;另一个场景里,它拿到一个模棱两可的需求,不主动确认,直接按最坏的猜想去执行,把一批测试数据给覆盖了。
那段时间我几乎每天都在“救火”,后来复盘时发现:模型本身的推理能力没有任何问题,问题出在“没有缰绳”。它不知道自己的权限边界是什么,不知道什么情况下该停下来问人,不知道重试几次后必须走回退路径。这个认知直接把我推向了 Harness 工程——也就是给 Agent 套上一整套行为约束、工具权限、状态管理和回退机制。
1.1 所谓 Harness 工程:给 Agent 套上“缰绳”
Harness 这个词,英文原意是马具、缰绳。用在 AI Agent 领域,指的是围绕 Agent 构建的一层“约束与支撑系统”:既给它动力,也限定它的活动范围。这个说法在 Claude Code 的实践社区里传播得很广,大家讨论的 harness 工程之道,核心就是通过 system prompt 管理、工具白名单、权限控制、结果校验、回退策略等手段,让模型在可控边界内发挥能力。
打个生活化的比方:一个刚拿到驾照的新手司机,车是好车,动力也足,但你不能直接让他上高速。你得先给车装上辅助刹车、车道偏离预警,再限定他只能在固定路线上开,副驾还得坐个教练。Harness 就是这套辅助系统和教练规则的总和。模型是司机,Harness 是车上的安全机制和交规。
这里有个容易混淆的概念:很多开发者把“写好 system prompt”等同于“做好 Harness”。这是完全不够的。System prompt 只是约束的一部分,而且是最容易被模型“选择性忽略”的那部分。真正的 Harness 必须包含硬约束——工具调用权限由代码拦截、重试次数由代码控制、输出格式由代码校验,这些不能指望模型“自觉”。
1.2 稳定不是玄学:先把三个问题说清楚
在做 Harness 之前,得先把“不稳定”拆开看。我在项目里把 Agent 的不稳定归成三类,每一类都有不同的解法切入点。
第一类是决策不稳定。同一个问题问两次,Agent 给的方案不一样,甚至第二次给出的方案是错误的。这类问题根源在于模型采样有随机性,以及上下文里的无关信息干扰了推理。解法方向是降低温度参数、精简上下文、用结构化 prompt 模板。
第二类是执行不稳定。Agent 计划做得挺好,但执行时工具调用失败、参数传错、接口超时,然后它情绪化地“硬编”一个结果出来。这类问题根源在于缺少对工具返回值的强制校验和失败重试策略。解法方向是给每个工具调用设计明确成功/失败判定标准。
第三类是资源不稳定。并发一上来,token 消耗失控、队列堵塞、超时率飙升。这类是纯工程问题,模型能力再强也救不了,必须在架构层面做排队、限流、超时管理和并发隔离。
把这三类问题一一对应到 Harness 的几个机制上,整个工程思路就清晰了。接下来我详细拆解这些机制。
2. Harness 工程的四个核心机制拆解
很多人第一次听到“Harness 工程”会觉得这是个新鲜概念,但拆开看,它其实就是一套组合拳——把传统后端开发里成熟的手段(权限控制、重试机制、状态机、可观测性)应用到 Agent 场景里。下面这四个机制是我在每个项目里都会落地的核心,缺一个,稳定性就会明显打折。
2.1 角色与上下文约束:让 Agent 知道“我是谁、能干什么”
第一层约束是角色系统。这里说的角色不只是 system prompt 里写一句“你是一个乐于助人的助手”,它要回答的是三个具体问题:你是谁、你能调用什么工具、你不能做什么。
我在实际配置里,会把角色定义拆成五段式的模板:
- 身份定位:一句话说明 Agent 服务对象和领域边界,例如“你是电商数据分析助手,只处理订单与商品相关的查询”;
- 工具清单:明确列出可用的工具名称,而不是让模型猜测,例如“可用工具:search_orders、get_product_info、generate_report”;
- 硬性禁止:明确列举绝对不能做的事,例如“禁止执行删除操作”“禁止越过查询权限直接访问原始表”;
- 不确定响应策略:遇到模糊问题时必须向用户确认,例如“当查询条件缺失时,列出你理解的参数并要求用户确认”;
- 输出格式要求:定义结构化输出模板,例如“必须返回 JSON,包含 data 和 confidence 两个字段”。
这段 prompt 看起来简单,但效果非常显著。它本质上是在模型推理之前先圈定了一个“求解空间”。实验数据上,加了硬性禁止和不确定响应策略之后,Agent 在模糊任务上的误操作率能下降一半以上。
这里有一个值得单独强调的细节:角色约束不要试图用“禁止”去覆盖模型的所有潜在错误行为,因为列不完。正确的做法是抓大放小——只禁止那些会给系统带来不可逆影响的行为(删除、写库、打钱),其余行为交给工具权限层去拦截。Prompt 是软约束,工具权限是硬约束,两者的配合才是完整的 Harness。
2.2 工具权限与白名单:把每把刀都收进刀鞘
如果说角色约束是“教育”,那工具权限就是“制度”。我给 Agent 的每个工具都设置了访问级别,核心是“按最小权限原则”配置。不是 Agent 能调什么就给它什么,而是它需要什么才给它什么。
实际项目里,我维护了一张工具权限矩阵表,大致长这样:
| 工具名称 | 功能说明 | 权限级别 | 允许调用条件 |
|---|---|---|---|
| search_orders | 查询订单 | 只读 | 任意会话 |
| get_customer_info | 查询客户信息 | 只读(脱敏) | 需用户明确授权 |
| update_order_status | 更新订单状态 | 写操作 | 需二次确认 |
| delete_record | 删除数据 | 禁止调用 | 永不开放 |
这张表落到代码里,就是在工具注册时增加一个 middleware 层,每次模型发起工具调用请求,先由代码检查该工具的权限级别、调用参数、是否满足条件,不满足的直接拒绝并返回提示。权限检查放在模型调用层之外,这样即使模型“想”越权,它也没有能力越权。
这个机制解决了一个很隐蔽的问题:模型经常会“顺手”做一个超出用户预期的操作。比如用户问“这个订单怎么没发货”,模型可能为了展示能力,直接调用了更新状态的工具把订单给改了。有了工具权限层,这种操作会被代码拦下来,强制转回“仅查询”路径。
另外,工具调用的输入也需要做 schema 校验。模型生成的参数偶尔会是错的——字段名称写错、枚举值不在范围内、时间格式不符合预期。用 JSON Schema 对参数做一层强校验,能够把大部分参数错误拦截在真正调用之前。校验失败的信息要回传模型,让它“自我修正”一次,再失败就走回退。
2.3 护栏与回退机制:错误不是失败,是必经路径
我在给团队做分享时反复强调一句话:不要试图让 Agent 不犯错,要预设它会犯错,并且为错误设计好逃生通道。
护栏机制的核心是一个三层回退金字塔,越往下越保守。第一层是自动修正:工具调用失败时,把错误信息返回给模型,让它在限定次数内重试。这一层适合参数小错误、临时超时这类问题。第二层是降级方案:自动修正失败后,切换到一个更简单的工具或默认策略。比如查询接口超时,那就先查本地缓存副本;缓存也没有,就返回一个“数据暂不可用”的明确结果,而不是硬编一个数。第三层是人工接管:所有自动路径都失败时,生成一个可读的错误报告,标记该会话为“需要人工介入”,推给值班人员处理。
这三层回退必须显式写在代码里,而不是靠 prompt 里的“如果失败请重试”。我见过太多项目把回退逻辑写在自然语言里,结果 Agent 在失败后仍然自我发挥,产生更离谱的结果。
一个具体的经验:重试次数不要设置为固定值,最好与错误类型关联。网络超时类错误可以重试 2-3 次,参数校验类错误重试 1 次(因为参数错说明模型理解有问题,再试大概率还是错),鉴权类错误直接放弃并升级人工。固定重试次数往往是制造“重试风暴”的元凶。
2.4 任务分解与状态管理:把大目标切成可回滚的小步骤
Agent 的另一个不稳定源是“一次性完成一个大任务”。比如让 Agent“分析近三个月所有品类的销售趋势并生成 PPT 报告”,这么大的目标,模型在执行过程中很容易丢步骤、卡中间、或者结果和用户预期南辕北辙。
Harness 工程在这里的解法是任务分步化与状态持久化。也就是把一个大的 Agent 任务拆成多个可以独立验证的小步骤,每一步都记录状态(进行中/成功/失败),失败时可以从最近的成功状态继续,而不是整个任务推倒重来。
技术实现上,用 LangGraph 这类图状态框架会非常方便。LangGraph 天然支持把 Agent 的 workflow 建成一张状态图,每个节点是一个处理步骤,节点之间有明确的转换条件,每一步的执行结果都写入持久化存储(比如 Redis 或数据库)。这样即使进程崩溃、网络中断,恢复后也能从 checkpoint 继续,而不是让用户重新问一遍。
我在实际项目里常用的状态结构大概长这样:
{ "task_id": "task_20250212_001", "status": "in_progress", "current_step": "data_fetch", "steps": [ {"name": "query_parse", "status": "done", "output": {...}}, {"name": "data_fetch", "status": "in_progress", "output": null} ], "checkpoints": ["query_parse"], "created_at": "2025-02-12T10:00:00Z", "updated_at": "2025-02-12T10:01:30Z" }每个 checkpoint 都保存了该步骤的输入输出摘要,这样模型在下一步推理时可以只加载相关的历史上下文,而不是把整个对话历史全塞进去。既省 token,也减少无关信息干扰。
3. 实操:一个可复现的稳定 Agent 搭建过程
讲完机制,落到实操。我以目前最顺手的一套技术组合为例——FastAPI + LangGraph + 一个支持 function calling 的模型——带大家完整走一遍搭建过程。这套组合的好处是:FastAPI 负责外部接口和并发控制,LangGraph 负责 Agent 内部的状态流转与任务图,模型只负责“决策”而不是“管理流程”,职责分离得很干净。
3.1 技术选型与架构:FastAPI + LangGraph 的取舍
选型阶段我比较过几套方案。第一套是纯 LangChain 的 AgentExecutor,简单但可控性差,内置的 Agent 循环像一个黑盒,你很难在中间步骤插入校验逻辑。第二套是自研状态机加模型调用,可控性最强但要写的代码太多,不适合快速迭代。第三套就是 LangGraph,它把状态图、条件跳转、checkpoint 都封装好了,同时保留了足够的自定义空间,是在开发效率和可控性之间最平衡的选择。
FastAPI 作为接入层则是顺理成章的选择。它天然支持异步,配合 asyncio 队列可以做请求排队,配合 Redis 可以做分布式限流,而且 OpenAPI 文档能直接暴露给前端团队联调。整个架构的分层是这样的:
| 层级 | 职责 | 技术选型 |
|---|---|---|
| 接入层 | HTTP接口、鉴权、限流、排队 | FastAPI + Redis |
| 编排层 | Agent状态图、节点跳转、checkpoint | LangGraph |
| 决策层 | 模型调用、工具选择 | 支持 function calling 的 LLM |
| 工具层 | 实际业务操作(查库、调接口) | 自研工具集 + 权限middleware |
| 数据层 | 状态存储、对话历史、缓存 | Redis + PostgreSQL |
这个分层的核心思想是:不要让模型直接接触工具层,模型只能通过编排层的中介去调用工具。编排层负责校验、记录、重试、回退,所有“非智能”的确定性逻辑都从模型手里接管出来。
3.2 把 Harness 落进代码:角色、工具、护栏的配置示例
下面我贴一段基于 LangGraph 的简化代码,展示 Harness 最关键的工具权限拦截和重试回退是怎么写的。这段代码我做了简化,但核心逻辑是完整可用的。
from fastapi import FastAPI, HTTPException from langgraph.graph import StateGraph, END from pydantic import BaseModel, ValidationError from typing import TypedDict, Optional import asyncio import json # 1. Agent 状态定义 class AgentState(TypedDict): user_query: str current_tool: Optional[str] tool_result: Optional[str] retry_count: int final_answer: Optional[str] need_human: bool # 2. 工具权限矩阵(硬约束) TOOL_PERMISSIONS = { "search_orders": {"level": "read", "allowed": True}, "get_customer_info": {"level": "read_masked", "allowed": True, "require_authorization": True}, "update_order_status": {"level": "write", "allowed": False, "require_confirmation": True}, "delete_record": {"level": "destroy", "allowed": False}, } # 3. 工具调用统一入口(中间层拦截) async def call_tool(tool_name: str, params: dict, user_context: dict) -> dict: perm = TOOL_PERMISSIONS.get(tool_name) # 硬约束检查 if perm is None: return {"ok": False, "error": "tool_not_found", "message": f"工具 {tool_name} 不存在"} if not perm["allowed"]: return {"ok": False, "error": "permission_denied", "message": f"工具 {tool_name} 未授权调用"} if perm.get("require_authorization") and not user_context.get("authorized_tools", {}).get(tool_name): return {"ok": False, "error": "auth_required", "message": f"工具 {tool_name} 需要用户明确授权"} # 参数校验(这里以搜索订单为例) if tool_name == "search_orders": try: validated = SearchOrdersParams(**params) except ValidationError as e: return {"ok": False, "error": "invalid_params", "message": str(e)} # 实际业务调用... result = await do_search_orders(validated) return {"ok": True, "data": result} # ... 其他工具分支 # 4. LangGraph 节点:模型决策 + 回退逻辑 async def model_decision_node(state: AgentState) -> AgentState: # 在真实项目里这里会调用 LLM 的 function calling # 这里简化为一个模拟决策 tool_name, params = await llm_dispatch(state["user_query"]) if tool_name is None: # 模型认为不需要工具,直接回答 state["final_answer"] = "已直接回答用户问题" return state result = await call_tool(tool_name, params, user_context=state.get("user_context", {})) if result.get("ok"): state["tool_result"] = json.dumps(result["data"], ensure_ascii=False) state["retry_count"] = 0 return state # 回退逻辑:按错误类型决定是否重试 if state["retry_count"] < max_retry_for_error(result["error"]): state["retry_count"] += 1 # 返回给模型进行修正,这是重试 state["tool_result"] = f"工具调用失败: {result['message']},请修正后重新调用" return state else: # 达到重试上限,降级或转人工 state["need_human"] = True state["final_answer"] = "工具调用多次失败,已转人工处理,请稍后查看结果" return state # 5. 图编排 graph = StateGraph(AgentState) graph.add_node("decision", model_decision_node) graph.add_node("respond", lambda state: state) graph.set_entry_point("decision") graph.add_conditional_edges( "decision", lambda state: "respond" if state.get("final_answer") else "decision", ) graph.add_edge("respond", END) agent_app = graph.compile()这段代码有四个关键点值得细看。第一,权限检查在call_tool这个统一入口里完成,而不是依赖模型自觉,这是硬约束的落地方式。第二,重试次数跟错误类型挂钩,max_retry_for_error是一个函数,根据错误码返回不同上限。第三,失败信息会回传给模型,给模型一次修正的机会,但不能无限修正。第四,need_human是一个“求救”信号,一旦置位,整个流程就转入人工处理路径,而不是让 Agent 继续低质量地硬撑。
3.3 并发与资源控制:让 Agent 扛得住真实流量
“AI Agent 怎么扛并发”是近期被问得最多的问题之一。很多 Agent 在单用户测试时表现很好,一上并发就崩,原因往往是没做资源隔离和队列控制。LangGraph 本身是单会话状态机,并发能力取决于你把它跑在什么样的执行环境里。
我采用的方案是“FastAPI 异步 + 工作队列 + 并发隔离”。每个用户的 Agent 会话是一个独立任务,放进队列,由一个 Worker 池消费。关键配置有三个:
第一个是并发上限。通过 Web 框架的信号量(asyncio.Semaphore)控制同时执行的 Agent 任务数量。这个值需要根据你的模型 API 限流条件做压力测试来确定,一般从 5 开始压,逐步加大到 20、50,观察 P95 延迟和错误率。需要注意的是:并发不是越大越好,盲目增加并发只会让模型 API 触发限流,反而把整体吞吐拖垮。
第二个是非阻塞架构。Agent 执行过程中要调用模型 API、要查询数据库,这些全是 IO 操作。在 FastAPI 里必须用 async/await 把它们承接住,否则一个 Agent 任务在等 API 响应时,整个进程的线程就被占用了,后面的任务排长队。
第三个是 Redis 分布式锁。同一个用户同时开了两个会话?或者同一个任务被重放了?用 Redis 加一把简单的分布式锁,保证同一时刻同一个任务只被执行一次,避免重复写数据、重复扣费这类低级但严重的故障。
这里放一个用 Semaphore 控制并发的简化例子:
from asyncio import Semaphore from contextlib import asynccontextmanager # 全局信号量,限制同时执行的 Agent 任务数 AGENT_SEMAPHORE = Semaphore(20) @asynccontextmanager async def agent_task_guard(): async with AGENT_SEMAPHORE: yield @app.post("/agent/run") async def run_agent(request: AgentRequest): async with agent_task_guard(): # 在信号量保护下运行 Agent 任务 result = await run_agent_workflow(request) return result信号量的限制是整个进程级别的,如果要做到多实例共享,需要换成 Redis 的并发计数器。但对于大多数中小型项目,进程内信号量已经足够。
4. 高频问题与排查记录
写这套系统的过程中,我积累了厚厚一沓问题排查笔记。下面挑几个经典问题分享,每个都是真实踩过的坑,希望能帮你省掉几天的排查时间。
4.1 Agent 答非所问或幻觉
这是最让人头疼的问题之一。排查时要先分清是模型推理问题还是 Harness 约束问题。我的排查顺序是:先看有没有工具被调用。如果 Agent 能正确选择工具并获取到真实数据,但最终答案仍然出现编造内容,那问题多半在“总结环节”——模型拿到了数据但没忠实引用。
解决方式是育成“引用优先”的输出模板:强制要求最终答案里的事实性内容必须标注来源工具和记录 ID。例如“根据订单表(来源:search_orders,记录 ID 8842),本月退货率为 3.2%”。如果模型引用的记录 ID 与工具返回不一致,说明它在编造,代码层需要检测到这种不一致并驳回重试。
另外一个排查思路是检查你的 system prompt 是否塞了太多无关内容。上下文越长,模型越容易“迷失”在无关信息里,导致它忽略了关键任务指令。我测试过,把一个 2000 字的 system prompt 压缩到 400 字,幻觉率明显下降。Prompt 不是越长越好,信息密度才是关键。
4.2 工具调用失败与循环重试
我在项目里遇到过最诡异的问题:Agent 在一个失败的工具调用上反复重试,而且每次重试参数都略有不同,看起来像是在“碰运气”,实则在疯狂消耗 token。这种情况的根因通常是错误信息回传给模型时不够明确,模型以为自己参数传错了,于是不断微调参数,但真正的问题其实是后端服务宕机。
解决办法是在调用失败时把错误类型明确地标记为“不重试类错误”。我采用了一个简单可行的方法:错误消息带上错误码前缀。例如:
[RETRYABLE_TIMEOUT]网络超时,模型可以重试;[FATAL_INTERNAL_ERROR]服务内部错误,模型不要重试;[VALIDATION_FAILED]参数校验失败,模型修正参数后最多再试一次。
模型虽然不能 100% 理解这些标记,但配合代码层的重试计数器,双保险之下,“重试风暴”的问题基本能被根治。
4.3 并发场景下的令牌与队列问题
AI Agent 项目里的 token 消耗是实打实的钱,并发一上去,控制不好账单就看天吃饭了。我在并发场景下踩过最大的坑是:每个会话的历史消息不断累积,token 用量呈线性增长,最后导致单次请求超过模型上下文窗口,直接报错。
现在的做法是上下文裁剪加摘要压缩。对话超过一定轮数后(通常是 10-15 轮),把早期对话交给一个轻量模型生成摘要,只保留摘要和最近几轮完整消息。这个策略能省 40% 以上的 token,同时不损伤对话连贯性。
另一个经验是给每个会话设定 token 预算。用 Redis 记录每个用户每小时的 token 消耗,超过预算直接拒绝新请求并提示“额度已用完”。这虽然会限制用户,但比起月底收到巨额账单,限制用户的体验问题显然更容易接受。
4.4 调试技巧:从日志到追踪
Agent 系统调试的难点在于“决策不可见”。你不知道模型为什么选这个工具、为什么拒绝执行、为什么走了回退分支。传统的 print 日志在单会话调试时还能用,但并发场景下根本凑不齐一条完整链路。
我强烈建议在 Agent 系统里接入全链路追踪,最简单的做法是给每次会话生成一个 trace_id,并在每个节点(模型调用、工具调用、权限检查、回退分支)都输出结构化日志:
{ "trace_id": "trace_7f3a9d2e", "node": "model_decision", "action": "tool_selected", "tool_name": "search_orders", "params": {"date_from": "2025-01-01"}, "latency_ms": 842, "token_cost": 1200, "timestamp": "2025-02-12T10:00:01Z" }有了 trace_id,用户报问题时只要把 ID 发过来,我就能把整条执行链路拉出来看。排查效率至少翻倍。如果你还没有做这个,建议列入下个迭代周期的第一优先级。
5. 工程落地的几条个人经验
最后结合我做过的几个项目,聊几条工程落地层面的经验。这些不是教科书里的方法论,是花了不少学费换来的。
5.1 从小场景切入,别想一口吃成胖子
最开始我做 Agent 时总想做一个“全能的业务助手”,什么都能问、什么都能做,结果就是什么场景都不稳定。后来改成只做“订单查询助手”,工具只有三个,权限边界非常清晰,一周就稳定上线了。稳定之后,再逐步扩展商品查询、报表生成等能力。你每加一个工具,系统的“不稳定面”就会大一圈,所以宁可小步慢跑,也不要一次给 Agent 太多能力。
这也是 Harness 工程的核心精神:给 Agent 的能力做“减法”而不是“加法”。能力少一个,要约束的就少一圈,稳定性自然随之提升。如果你的 Agent 经常在多个工具之间犹豫不决,那大概率是工具数量超出了它的决策带宽。
5.2 评测比调 Prompt 更重要
我见过团队花两周时间调 prompt,却没有任何一个评测数据集来度量改动好坏。这完全是本末倒置。Prompt 调整是“感觉良好”,评测才是“数据说话”。
我现在的做法是:为每个 Agent 维护一个测试集,里面固定 30-50 条覆盖正常、边界、异常场景的测试用例,每次改动 Harness 配置或 prompt 后,全量跑一遍,用通过率判断改动是否有效。借助 LangSmith 这类平台采集真实用户交互来人工打标,就能追出实际效果指标。这套流程跑起来后,Agent 的迭代才真正进入工程化轨道。
5.3 Harness 不是一次性工作,是持续演进的约束层
我第一次做完 Harness 设置时觉得终于搞定了,后来发现模型升级、业务变化、新工具的加入,都会让旧约束失效。比如某次模型从 V1 升级到 V2,原本能被约束住的某些行为突然又冒出来了,因为新模型对 prompt 的理解方式变了。
所以现在我把 Harness 当做一个独立于 Agent 业务流程的“软件层”来维护。每次模型版本升级,都会用评测集回归一遍所有约束是否仍然有效;每个新工具的加入,都要先过一遍权限矩阵评审,再进测试集。这一层不是写一次就完事,它是一套“活”的工程系统。
就聊到这里。如果你正在做的事情跟这个类似,希望这些思路能帮你少踩几个坑。尤其是那个观点——“模型负责聪明,代码负责稳定”,建议你从下个项目开始试试。把确定性的逻辑尽量从 prompt 里挪到代码里,你会明显感受到 Agent 的可靠性上升一个台阶。