欢迎光临
我们一直在努力

给 Agent 装上护栏——harness 层设计

给 Agent 装上护栏——harness 层设计

这是「Agent 工程化」系列的第九篇。前面八篇把 Agent 的"能力"讲完了:会动手、记性好、懂业务、有经验、还会拆任务。但能力越强越让人害怕——它跑飞了怎么办? 这篇讲护栏(harness):预算、白名单、重试、断点恢复,让"能干"的 Agent 也"听话"。


跑飞现场:上线第一天,钱花光了还在跑

Agent 上线第一天的真实场景:

用户:帮我查一下这个行业的最新动态
Agent:(调用搜索工具,第 1 次)搜到 10 条结果
Agent:(第 2 次)嗯,再搜下另一个关键词……
Agent:(第 30 次)……
Agent:(第 200 次)还没完,再搜搜……

用户早走了,Agent 还在自己循环:搜索工具被调了几百次,token 费用哗哗涨,但没人能叫停它。

为什么会这样?回看第二篇那个核心循环:

while 任务未完成: # ← 问题在这
llm 输出 { 思考, 行动 }
执行工具

循环的条件是"任务未完成"——谁来判断完成?LLM 自己。 而 LLM 可能陷入"再搜一次也许有结果"的死循环。循环是油门,没有刹车。

护栏要做的,就是给这个循环装上刹车。专业点说:在 Agent 循环外面包一层 harness(缰绳),它不管 Agent 怎么想,只管"不许越过这些线"。


护栏的整体结构:循环外的一层壳

#mermaid-svg-WCybsuPNNQ5FIrp8{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WCybsuPNNQ5FIrp8 .error-icon{fill:#552222;}#mermaid-svg-WCybsuPNNQ5FIrp8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WCybsuPNNQ5FIrp8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .marker.cross{stroke:#333333;}#mermaid-svg-WCybsuPNNQ5FIrp8 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WCybsuPNNQ5FIrp8 p{margin:0;}#mermaid-svg-WCybsuPNNQ5FIrp8 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster-label text{fill:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster-label span{color:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster-label span p{background-color:transparent;}#mermaid-svg-WCybsuPNNQ5FIrp8 .label text,#mermaid-svg-WCybsuPNNQ5FIrp8 span{fill:#333;color:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .node rect,#mermaid-svg-WCybsuPNNQ5FIrp8 .node circle,#mermaid-svg-WCybsuPNNQ5FIrp8 .node ellipse,#mermaid-svg-WCybsuPNNQ5FIrp8 .node polygon,#mermaid-svg-WCybsuPNNQ5FIrp8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .rough-node .label text,#mermaid-svg-WCybsuPNNQ5FIrp8 .node .label text,#mermaid-svg-WCybsuPNNQ5FIrp8 .image-shape .label,#mermaid-svg-WCybsuPNNQ5FIrp8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WCybsuPNNQ5FIrp8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .rough-node .label,#mermaid-svg-WCybsuPNNQ5FIrp8 .node .label,#mermaid-svg-WCybsuPNNQ5FIrp8 .image-shape .label,#mermaid-svg-WCybsuPNNQ5FIrp8 .icon-shape .label{text-align:center;}#mermaid-svg-WCybsuPNNQ5FIrp8 .node.clickable{cursor:pointer;}#mermaid-svg-WCybsuPNNQ5FIrp8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .arrowheadPath{fill:#333333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WCybsuPNNQ5FIrp8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WCybsuPNNQ5FIrp8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WCybsuPNNQ5FIrp8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster text{fill:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 .cluster span{color:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WCybsuPNNQ5FIrp8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WCybsuPNNQ5FIrp8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WCybsuPNNQ5FIrp8 .icon-shape,#mermaid-svg-WCybsuPNNQ5FIrp8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WCybsuPNNQ5FIrp8 .icon-shape p,#mermaid-svg-WCybsuPNNQ5FIrp8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WCybsuPNNQ5FIrp8 .icon-shape .label rect,#mermaid-svg-WCybsuPNNQ5FIrp8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WCybsuPNNQ5FIrp8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WCybsuPNNQ5FIrp8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WCybsuPNNQ5FIrp8 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

用户提问

harness 前置检查预算还有吗 工具在名单里吗

Agent 循环思考 行动 观察

harness 后置检查超预算了吗 死循环了吗

一切正常

强制收尾告知用户已达上限

harness 不管"怎么完成任务",只管四件事:预算、白名单、重试、断点。逐个拆。


核心一:三层预算——先定义"多少钱算完"

跑飞的本质是没有成本上限。解法是给循环套三层预算,任何一层超了都强制收尾:

层级管什么例子
每次调用 单次工具调用的开销上限 单次返回最多 2000 token
每轮循环 单轮思考+行动的 token 单轮最多 1500 token
整个会话 总调用次数 / 总 token / 总费用 最多 20 次工具调用,总费用 0.5 元

class Budget:
def __init__(self):
self.max_calls = 20 # 整个会话最多调 20 次工具
self.max_cost = 0.5 # 最多花 0.5 元
self.calls = 0
self.cost = 0.0

def check(self) > bool:
"""循环每轮开始时检查,超了就刹车"""
return self.calls < self.max_calls and self.cost < self.max_cost

循环每轮都问一句"预算还够吗":

budget = Budget()
while not task_done and budget.check(): # 刹车在这里
... # Agent 循环本体
budget.calls += 1
budget.cost += round_cost(...)
else:
if not task_done:
finish_gracefully("已达本次会话上限,先到这里") # 优雅收尾

优雅收尾是关键:预算耗尽不是硬中断,而是告诉用户"已达上限,需要的话可以继续"。


核心二:工具白名单——只允许它碰该碰的

Agent 能力越强,越要给它的工具划定范围:

方式说明风险
黑名单 默认都可用,禁止某些 漏一个就出事
白名单 默认都不可用,只放行名单内的 最安全,写操作天然受限

生产里两个原则:

  • 白名单 + 按场景下发:查资料任务只下发搜索工具,订票任务才下发下单工具——每个 Agent 看到的工具是"这个任务该有的"
  • 写操作强制人工确认:下单、付款、删数据这类工具,执行前必须过人工确认闸(第三篇讲过两道闸,harness 在这里落地)
  • WHITELIST = {
    "research_task": ["web_search", "read_url"], # 研究任务:只给查询工具
    "booking_task": ["search_flight", "book_ticket"], # 订票任务:才给下单工具
    }

    def allowed_tools(task_type: str) > list:
    return [t for t in ALL_TOOLS if t.name in WHITELIST[task_type]]

    白名单 = 不给模型做坏事的机会,比事后追责便宜得多。


    核心三:重试——什么时候重来,什么时候别重来

    工具调用会失败,但失败要分类——有的值得重试,有的重试等于烧钱:

    失败类型例子重试?
    网络抖动 连接超时、服务 503 重试(退避重试)
    限流 429 请求过多 重试(等几秒)
    参数错误 4xx、必填参数缺失 不重试(重试也一样错)
    逻辑错误 业务校验不通过 不重试(改参数才行)

    import time

    def call_with_retry(fn, *args, max_retries=3):
    for i in range(max_retries):
    try:
    return fn(*args)
    except NetworkError as e: # 5xx/429:退避重试
    time.sleep(2 ** i) # 1s, 2s, 4s 退避
    except ParameterError as e: # 4xx:参数错了,重试没用
    return f"参数错误: {e}" # 把错误反馈给模型让它改
    return "多次重试仍失败"

    一条判断口诀:网络层的问题重试,业务层的问题反馈给模型改。 把错误信息以工具结果形式喂回模型(第三篇讲过"反馈修正"),让模型自己改参数——这比重试高效得多。


    核心四:断点恢复——聊到一半崩了,能接着聊

    生产环境进程可能随时重启(发布、OOM、宕机)。用户聊到一半,会话就没了,这是最伤体验的事。

    断点恢复 = 会话状态持久化:

    #mermaid-svg-hBbUG8yKtf3DNXfK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hBbUG8yKtf3DNXfK .error-icon{fill:#552222;}#mermaid-svg-hBbUG8yKtf3DNXfK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hBbUG8yKtf3DNXfK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hBbUG8yKtf3DNXfK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hBbUG8yKtf3DNXfK .marker.cross{stroke:#333333;}#mermaid-svg-hBbUG8yKtf3DNXfK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hBbUG8yKtf3DNXfK p{margin:0;}#mermaid-svg-hBbUG8yKtf3DNXfK .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster-label text{fill:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster-label span{color:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster-label span p{background-color:transparent;}#mermaid-svg-hBbUG8yKtf3DNXfK .label text,#mermaid-svg-hBbUG8yKtf3DNXfK span{fill:#333;color:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK .node rect,#mermaid-svg-hBbUG8yKtf3DNXfK .node circle,#mermaid-svg-hBbUG8yKtf3DNXfK .node ellipse,#mermaid-svg-hBbUG8yKtf3DNXfK .node polygon,#mermaid-svg-hBbUG8yKtf3DNXfK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hBbUG8yKtf3DNXfK .rough-node .label text,#mermaid-svg-hBbUG8yKtf3DNXfK .node .label text,#mermaid-svg-hBbUG8yKtf3DNXfK .image-shape .label,#mermaid-svg-hBbUG8yKtf3DNXfK .icon-shape .label{text-anchor:middle;}#mermaid-svg-hBbUG8yKtf3DNXfK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hBbUG8yKtf3DNXfK .rough-node .label,#mermaid-svg-hBbUG8yKtf3DNXfK .node .label,#mermaid-svg-hBbUG8yKtf3DNXfK .image-shape .label,#mermaid-svg-hBbUG8yKtf3DNXfK .icon-shape .label{text-align:center;}#mermaid-svg-hBbUG8yKtf3DNXfK .node.clickable{cursor:pointer;}#mermaid-svg-hBbUG8yKtf3DNXfK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hBbUG8yKtf3DNXfK .arrowheadPath{fill:#333333;}#mermaid-svg-hBbUG8yKtf3DNXfK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hBbUG8yKtf3DNXfK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hBbUG8yKtf3DNXfK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hBbUG8yKtf3DNXfK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hBbUG8yKtf3DNXfK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hBbUG8yKtf3DNXfK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster text{fill:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK .cluster span{color:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hBbUG8yKtf3DNXfK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hBbUG8yKtf3DNXfK rect.text{fill:none;stroke-width:0;}#mermaid-svg-hBbUG8yKtf3DNXfK .icon-shape,#mermaid-svg-hBbUG8yKtf3DNXfK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hBbUG8yKtf3DNXfK .icon-shape p,#mermaid-svg-hBbUG8yKtf3DNXfK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hBbUG8yKtf3DNXfK .icon-shape .label rect,#mermaid-svg-hBbUG8yKtf3DNXfK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hBbUG8yKtf3DNXfK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hBbUG8yKtf3DNXfK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hBbUG8yKtf3DNXfK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    会话进行中

    每轮结束把状态落库messages + 已完成步骤 + 预算剩余

    进程崩溃 / 用户离开

    重启后从库恢复

    重建上下文

    接着上一轮继续


    def save_checkpoint(session_id, messages, budget):
    db.save(session_id, {"messages": messages, "budget": budget})

    def restore(session_id):
    state = db.load(session_id)
    return state["messages"], state["budget"] # 重建上下文继续跑

    注意:持久化的不只是 messages,还有预算剩余和已完成步骤——恢复后不能"白跑一遍"(那会重复扣费、重复下单)。


    踩坑:Agent 死循环,怎么都停不下来

    最经典的护栏翻车场景:

    场景:工具返回了一个错误,Agent 觉得"我没调好,再调一次",结果同一个错误循环重试了几十次——步数没超限,但轮次全部浪费在"重试同一个注定失败的调用"上。

    根因:步数上限只防"无限循环",防不了"低效循环"。

    解法:加一层连续失败检测——连续 N 次(比如 3 次)工具调用都失败或结果相同,就认定卡住了:

    CONSECUTIVE_FAIL_LIMIT = 3

    def should_stop(history) > bool:
    fails = 0
    for h in history[CONSECUTIVE_FAIL_LIMIT:]:
    if h.is_error or h.result == history[1].result:
    fails += 1
    return fails >= CONSECUTIVE_FAIL_LIMIT

    卡住后的收尾方式也有讲究,不是沉默硬停,而是"强制提示一次 + 标记未解决":

    助手:抱歉,我连续尝试了几次都未能完成(标记为未解决),
    可能是接口暂时不可用。建议稍后再试或联系人工客服。

    把"未解决"状态记录下来——这是第 11 篇评测的数据来源:哪些问题 Agent 搞不定,比哪些问题它搞定了,更有价值。


    小结

    • 护栏 = 循环外的壳,不管"怎么干",只管"不许越线"
    • 三层预算:每次 / 每轮 / 整个会话,超了就优雅收尾
    • 工具白名单:按场景下发,写操作人工确认,不给做坏事的机会
    • 重试分两类:网络层问题重试(退避),业务层问题反馈给模型改
    • 断点恢复:messages + 预算 + 已完成步骤都持久化,崩溃了接着聊
    • 死循环:连续失败检测 + 强制提示一次 + 标记"未解决"

    下篇预告

    护栏装好了,Agent 终于敢放心上线了。但上线只是开始——线上 Agent 出了岔子,你怎么知道? 它调用了几次工具、花了多少钱、哪一步答错了,这些必须有记录。

    下一篇讲 生产部署与可观测:日志、trace、指标、告警,让线上 Agent 的一举一动都在掌握之中。

    本系列路线(从 0 到 1): Agent 是什么 → 手写最小 ReAct → Function Calling 与工具设计 → 上下文管理 → Memory 记忆系统 → RAG 知识库 → Skill 自学习 → 编排模式与多 Agent → 给 Agent 装护栏 → 生产部署与可观测 → 评测与回归

    赞(0)
    未经允许不得转载:171主机测评 » 给 Agent 装上护栏——harness 层设计
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址