1. 从一次裸调用说起:为什么单次 LLM 调用撑不起一个 Agent
很多人第一次接触 Agent,脑子里想的其实还是“给大模型发一段 prompt,它回一段话”。这个认知在 Demo 阶段没问题,但一旦你想让它自己查资料、调工具、改代码、跑测试、再根据结果决定下一步,单次调用立刻就不够用了。原因很直接:一次 LLM 调用是无状态、单轮、无反馈的,它没有记忆、没有工具、没有循环、没有错误恢复,本质上只是一个“文本进、文本出”的函数。
我刚开始做 Agent 的时候也踩过这个坑。当时写了个脚本,把用户问题拼成 prompt,调一次模型,拿到回答就返回。跑简单问答没问题,但一旦让它“帮我查一下这个报错然后改代码”,它就开始胡说——因为它根本没有执行环境,也没有办法把执行结果喂回给自己。后来我才意识到,Agent 的核心不是“模型多强”,而是模型外面那层 Runtime 怎么设计。
所以这篇文章想聊的,不是某个具体框架怎么用,而是从一次 LLM 调用出发,一步步推导到完整 Harness Runtime 的架构选型思路。我会把每个阶段的核心矛盾、技术选型理由、参数计算、实操细节都拆开讲,尽量让你看完能直接对照自己的场景做决策。适合已经写过简单 LLM 调用、想往 Agent 方向深入的同学,也适合正在纠结“到底要不要上框架”的开发者。
先给一个整体判断:Agent 架构的复杂度,应该由任务的可逆性和失败成本决定,而不是由模型能力决定。任务越不可逆、失败成本越高,Runtime 就越需要重的 Harness;反之,能省则省。这个原则会贯穿全文。
2. 架构分层的核心逻辑:从裸调用到 Harness Runtime 的四级演进
2.1 第一级:裸调用(Bare Call)——只适合确定性任务
裸调用就是最朴素的prompt -> completion。它的技术实现通常是一个 HTTP 请求或者 SDK 调用,参数无非是model、messages、temperature、max_tokens这几个。我实测下来,这种模式适合的任务类型非常有限:文本分类、信息抽取、简单改写、固定格式生成。
它的优势是延迟低、成本可控、调试简单。但问题也很明显:没有上下文管理,多轮对话要自己拼 messages;没有工具调用,模型只能靠“编”;没有重试和校验,输出格式错了只能自己兜。很多人觉得“我加个 JSON schema 约束就行了”,但模型偶尔还是会漏字段,这时候没有 Runtime 兜底,线上就会出问题。
注意:裸调用不是“低级”,而是“够用就好”。如果你的任务输出可枚举、失败可重试、无副作用,裸调用反而是最稳的选择,别为了架构而架构。
2.2 第二级:带工具的单轮调用(Tool-Augmented Call)——引入外部能力
当模型需要查实时数据、算数学、访问数据库时,就要引入工具调用。技术实现上,通常是把工具定义成 JSON Schema 传给模型,模型返回tool_calls,你的代码执行工具,再把结果作为tool角色消息拼回 messages,发起第二次调用。
这里有个关键参数:工具描述的质量直接决定调用准确率。我试过把工具描述写得含糊,模型经常选错工具或者传错参数。后来我把每个工具的描述改成“什么时候用、什么时候不用、参数含义、返回格式”四段式,调用准确率明显提升。
这一级的核心矛盾是:工具执行结果如何影响下一步决策。如果只是单轮“调用一次工具就结束”,那还是简单;但如果要根据工具结果决定是否再调、调哪个,就自然过渡到下一级。
2.3 第三级:循环式 Agent(Loop Agent)——ReAct 与 Plan-Execute
循环式 Agent 的核心是一个while循环:模型输出思考或动作,Runtime 执行动作,把观察结果拼回上下文,再让模型决定下一步,直到模型输出终止信号或达到最大轮数。
这里有两种主流范式。ReAct是“想一步、做一步、看一步”,适合探索性任务,比如调试、搜索。Plan-Execute是先让模型出完整计划,再逐步执行,适合步骤明确的任务,比如数据处理流水线。我个人的经验是:任务路径不确定时用 ReAct,路径确定但步骤多时用 Plan-Execute,两者也可以混合——先 Plan 再 ReAct 执行每一步。
关键参数是最大循环轮数和上下文窗口管理。轮数设太小,任务做不完;设太大,成本和延迟爆炸。我一般会按“任务预期步骤数 × 2”来设上限,同时监控每轮 token 消耗。上下文管理上,简单做法是保留最近 N 轮,复杂做法是做摘要压缩,把早期轮次压缩成一段总结。
2.4 第四级:完整 Harness Runtime——生产级 Agent 的底座
到了生产环境,光有循环还不够。你需要处理:并发请求、超时重试、工具权限、输出校验、状态持久化、可观测性、成本控制、安全隔离。这些加起来就是 Harness Runtime。
“Harness”这个词的本意是“挽具、约束装置”,很形象——它不是让模型更自由,而是让模型在可控范围内工作。一个完整的 Harness Runtime 通常包含这些模块:调度器、上下文管理器、工具执行器、校验器、状态存储、日志与追踪、限流与熔断。
我踩过最大的坑是:早期没做状态持久化,Agent 跑到一半进程挂了,整个任务要从头再来,成本直接翻倍。后来我把每轮状态落盘,支持断点续跑,才稳下来。所以如果你要做长任务 Agent,状态持久化不是可选项,是必选项。
3. 核心模块拆解:Harness Runtime 里每个部件怎么选
3.1 调度器:同步、异步还是事件驱动
调度器决定 Agent 怎么“跑起来”。最简单的同步调度就是函数调用,一轮接一轮。但生产环境里,工具调用往往是 IO 密集的,同步会浪费大量等待时间。我实测下来,异步调度在工具调用多的场景下吞吐能提升 3 到 5 倍。
再往上走是事件驱动:把每轮循环拆成事件,用消息队列串起来。好处是可扩展、可恢复、可并行,坏处是复杂度高、调试难。我的建议是:单机小规模用异步,多机大规模用事件驱动,别一上来就上队列,先把业务跑通再说。
3.2 上下文管理器:窗口、摘要与检索的取舍
上下文管理是 Agent 最容易被低估的模块。模型上下文窗口有限,任务一长就会溢出。常见策略有三种:滑动窗口(保留最近 N 轮)、摘要压缩(把旧轮次总结成一段)、外部检索(把历史存向量库,按需召回)。
我一般组合使用:近期轮次用滑动窗口保原文,中期用摘要,远期用检索。参数上,滑动窗口大小按“任务平均轮数 × 1.5”设,摘要触发阈值设在窗口的 70% 左右。这里有个细节:摘要本身也要调模型,会增加成本和延迟,所以摘要频率不能太高,我一般每 5 到 10 轮做一次。
3.3 工具执行器:沙箱、权限与超时
工具执行器是 Agent 的“手脚”,也是最危险的地方。如果工具能执行任意代码、访问任意文件,那 Agent 一旦被诱导就会出大事。所以生产级执行器必须做三件事:沙箱隔离、权限最小化、超时熔断。
沙箱可以用容器或轻量虚拟机,权限上按工具粒度授权,比如“读文件”和“写文件”分开。超时我一般设 30 秒,超过就中断并返回错误给模型,让它决定重试还是换方案。这里有个经验:工具返回的错误信息要结构化,比如{"error": "timeout", "tool": "search", "retryable": true},模型看到这种格式更容易做出正确决策。
3.4 校验器:输出格式与业务规则的双重把关
校验器负责在模型输出进入下一步之前做检查。第一层是格式校验,比如 JSON 是否合法、字段是否齐全;第二层是业务校验,比如生成的 SQL 是否只读、调用的 API 参数是否在允许范围。
我试过只做格式校验,结果模型生成了一个删除全表的 SQL,差点出事。后来加了业务规则校验,用白名单和正则双重过滤,才放心。校验失败时,不要把原始错误直接抛给用户,而是把错误信息拼回上下文让模型重试,通常一两次就能修正。
3.5 状态存储与可观测性:让 Agent 可恢复、可追踪
状态存储解决“跑到一半挂了怎么办”,可观测性解决“出问题怎么查”。状态存储我一般用键值库,每轮存{task_id, step, messages, tool_results, status},支持按 task_id 恢复。可观测性上,至少要有每轮的输入输出、token 消耗、耗时、工具调用记录,最好能串成一条 trace。
这里有个实用技巧:给每个任务生成唯一 trace_id,所有日志都带上它,排查时一搜就能看到完整链路。我早期没做这个,线上出问题只能靠猜,后来补上之后排查效率提升非常明显。
4. 实操落地:从零搭一个最小可用 Harness Runtime
4.1 环境与依赖准备
先明确技术栈。我用 Python 举例,核心依赖包括:模型 SDK、HTTP 客户端、异步框架、校验库、状态存储客户端。版本上建议锁定,避免线上和本地不一致。下面是一个最小依赖清单:
pip install openai httpx pydantic asyncio aiofiles redis这里pydantic用来做输出校验,redis用来做状态存储,httpx做异步 HTTP。如果你不想引入 Redis,可以先用本地文件,但生产环境建议上 Redis 或同类存储。
4.2 定义工具与 Schema
工具定义要遵循“四段式描述”。下面是一个搜索工具的示例:
tools = [ { "type": "function", "function": { "name": "search_docs", "description": "用于查询内部文档。当用户问题涉及产品用法、配置说明时使用;当问题涉及实时数据或计算时不要使用。", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "搜索关键词,建议 3 到 10 个字"}, "top_k": {"type": "integer", "description": "返回条数,默认 5,最大 10"} }, "required": ["query"] } } } ]注意top_k我设了最大值约束,这是防止模型传一个巨大数字导致查询变慢。参数约束越明确,模型越不容易乱来。
4.3 主循环实现与参数计算
主循环的核心逻辑是:调用模型 -> 判断是否有工具调用 -> 执行工具 -> 拼回上下文 -> 继续循环。下面是一个简化版实现:
async def run_agent(task, max_steps=10): messages = [{"role": "user", "content": task}] for step in range(max_steps): response = await call_llm(messages, tools=tools) msg = response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: result = await execute_tool(call) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result) }) messages = compress_context(messages) return "达到最大步数,任务未完成"max_steps怎么定?我的经验公式是:预期步骤数 × 2 + 2。比如一个查资料加总结的任务,预期 3 步,那就设 8。设太小任务做不完,设太大浪费成本。compress_context就是前面说的上下文压缩,触发阈值设在窗口 70%。
4.4 校验与重试的落地写法
校验器我一般写成独立函数,格式校验用 pydantic,业务校验用自定义规则:
from pydantic import BaseModel, ValidationError class SQLOutput(BaseModel): sql: str explanation: str def validate_sql(text): try: parsed = SQLOutput.parse_raw(text) except ValidationError as e: return False, str(e) if not parsed.sql.strip().lower().startswith("select"): return False, "只允许 SELECT 查询" return True, parsed校验失败时,把错误信息作为新消息拼回上下文,让模型重试。我实测下来,格式错误通常一次重试就能修正,业务规则错误可能需要两次。重试次数建议设 2 到 3 次,超过就终止并报错。
4.5 状态持久化与断点续跑
每轮结束后把状态写入存储:
async def save_state(task_id, step, messages, status): await redis.set(f"agent:{task_id}", json.dumps({ "step": step, "messages": messages, "status": status }))恢复时读取状态,从step + 1继续。这里有个细节:工具执行结果也要存,否则恢复后模型不知道之前执行过什么。我踩过的坑是只存了 messages 没存工具结果,恢复后模型重复调用同一个工具,浪费成本。
5. 常见问题与排查技巧实录
5.1 模型不调用工具,只输出文本
这是最常见的问题。原因通常有三个:工具描述不清、任务本身不需要工具、模型被系统提示误导。排查顺序是:先看工具描述是否明确写了“什么时候用”,再看系统提示是否说了“优先直接回答”,最后看任务是否真的需要工具。
我的解决方法是:在系统提示里明确写“当问题涉及实时数据或外部信息时,必须调用工具,不要凭记忆回答”。同时把工具描述改成四段式。实测下来,这两步能解决 80% 的不调用问题。
5.2 循环停不下来,一直调用工具
原因通常是模型没有收到明确的终止信号,或者工具返回结果让模型误以为任务没完成。排查时先看每轮的工具返回,是不是有错误信息让模型一直重试。如果是,检查工具是否真的执行成功;如果不是,检查系统提示是否说了“任务完成后直接输出最终答案,不要再调用工具”。
我一般还会加一个硬性保护:连续 3 轮调用同一个工具且参数相同,就强制终止并返回当前结果。这个保护救过我好几次。
5.3 上下文溢出导致模型失忆
长任务必然遇到上下文溢出。表现是模型忘记早期信息,重复问同样的问题。解决方法是上下文压缩,但压缩本身也有坑:摘要太粗会丢关键信息,太细又省不了多少 token。
我的经验是:摘要时保留“任务目标、已完成步骤、当前状态、待办事项”四要素,其他细节可以丢。摘要 prompt 要明确写“只保留对后续决策有用的信息”。实测下来,这样压缩后模型基本不会失忆。
5.4 工具执行超时或报错
工具超时是常态,关键是超时后怎么处理。我的做法是:超时返回结构化错误,标记retryable: true,让模型决定重试还是换方案。如果同一个工具连续超时 3 次,就熔断该工具,返回“该工具暂时不可用,请尝试其他方式”。
这里有个细节:超时时间要按工具类型区分。搜索类可以设 10 秒,代码执行类可以设 30 秒,数据库查询类可以设 5 秒。统一设一个值往往不是太短就是太长。
5.5 成本失控
Agent 成本失控通常来自三个地方:循环轮数过多、上下文过长、工具调用过多。排查时先看每轮 token 消耗,再看轮数分布。我的控制手段是:设最大轮数、设上下文压缩阈值、设单任务 token 预算。超过预算就终止并返回部分结果。
下面是一个常见问题速查表:
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 不调用工具 | 描述不清/提示误导 | 看工具描述和系统提示 | 四段式描述+强制提示 |
| 循环不停 | 无终止信号/工具报错 | 看工具返回和提示 | 加终止提示+重复调用保护 |
| 上下文溢出 | 轮数过多/未压缩 | 看 token 消耗 | 摘要压缩+四要素保留 |
| 工具超时 | 超时设置不合理 | 看工具类型 | 分类设超时+熔断 |
| 成本失控 | 轮数/上下文/工具过多 | 看 token 分布 | 设预算+最大轮数 |
6. 架构选型的决策框架与个人经验
6.1 按任务可逆性选架构
我总结了一个简单的决策框架:任务可逆且失败成本低,用裸调用或单轮工具调用;任务可逆但步骤多,用循环 Agent;任务不可逆或失败成本高,用完整 Harness Runtime。
举个例子:文本分类是可逆的,错了重跑就行,裸调用足够。查资料加总结是可逆的,但步骤多,用循环 Agent。自动改代码并提交是不可逆的,改错了要回滚,必须上完整 Harness,带校验、沙箱、状态持久化。
这个框架的好处是,它不依赖具体技术,而是从业务风险出发,避免过度设计或设计不足。
6.2 框架选型:自研还是用现成的
市面上 Agent 框架不少,我的建议是:先用现成框架跑通,再根据瓶颈决定是否自研。现成框架的好处是省时间,坏处是黑盒、难定制、升级不可控。我早期用框架,后来发现上下文管理和工具执行器不满足需求,才逐步替换成自研模块。
自研的时机是:现成框架的性能瓶颈明显、定制需求频繁、或者安全合规要求高。自研的代价是开发周期长、维护成本高,所以别轻易自研,除非你确定现成方案真的不够用。
6.3 我踩过的三个坑
第一个坑是过早优化。一开始就上事件驱动和消息队列,结果业务逻辑还没跑通,调试成本极高。后来退回异步调度,先把业务跑通,再逐步替换。
第二个坑是忽略状态持久化。长任务跑到一半挂了,从头再来,成本翻倍。后来补上状态存储和断点续跑,才稳下来。
第三个坑是工具权限过大。早期工具能执行任意代码,测试时差点删了重要文件。后来加了沙箱和权限白名单,才放心。
6.4 后续可以扩展的方向
如果你已经把最小 Harness 跑通,可以考虑这几个方向:多 Agent 协作(把任务拆给多个 Agent,各司其职)、工具自动发现(让 Agent 自己注册和发现工具)、在线学习(根据执行结果自动优化提示和工具描述)。
不过我的建议是,别急着上这些。先把单 Agent 的稳定性、成本、可观测性做扎实,再考虑扩展。很多团队死在“功能很多但没一个稳”上,而不是“功能少但很稳”上。
最后分享一个小技巧:给 Agent 加一个“干跑模式”,也就是工具只返回模拟结果,不真正执行。这样可以在不产生副作用的情况下测试整个循环逻辑,调试效率会高很多。我在改代码类 Agent 上一直用这个模式,先干跑验证流程,再开真实执行,省了很多回滚的麻烦。