在一线排查 Agent 性能问题时,最常见的误判是:用户反馈"Agent 太慢",于是直接认定是模型推理能力不行,准备换更大的模型。可等真正打开日志,往往会发现单次模型调用的首 token 延迟并不高,生成速度也正常,真正的瓶颈反而藏在调用结构里——同一个任务,Agent 前前后后调用了十几次甚至几十次模型。
这句话值得反复体会:Agent 慢,不是模型慢,是问得太多。一个任务如果只需要一次模型调用就能完成,却被拆成了六次串行调用,即便每次调用只要 2 秒,总延迟也会被放大到 12 秒以上。这种延迟增长不是模型造成的,是 Agent 的调用设计造成的。
这篇文章会把"Agent 慢"拆成可以量化、可以定位、可以优化的具体环节。你会看到 Agent 总延迟的组成公式,四种最容易导致"问得太多"的调用模式,一个用于统计调用链路的简易 Harness 脚本,以及一套从 6 次模型调用降到 1 到 2 次的优化思路。看完之后,再遇到"Agent 响应慢",第一反应不应该是换模型,而是先回答三个问题:一次任务到底调用了多少次模型?每次调用都真的必要吗?每次调用都在处理哪些 token?
1. 先拆开"Agent 慢"的总延迟公式
1.1 Agent 任务耗时不是单次模型耗时,而是调用链的累计耗时
很多人对 Agent 耗时的直觉是:任务耗时约等于一次模型调用的耗时。这个直觉在单轮对话里基本成立,但在 Agent 场景里完全不成立。
一个典型的 Agent 任务会经历多次"模型决策-工具执行-结果回填"循环。每次循环里,模型都要做一次推理,工具要执行一次真实操作,上下文会继续膨胀。任务越复杂,循环轮数越多,总耗时就越像一个串行管道,而不是单次请求。
更关键的是,Agent 的每一轮循环并不等价。第一轮时上下文可能只有几百 token,到第五轮时上下文已经累积到几千 token,单次调用的 prefill 时间会明显上升。也就是说,调用次数多不仅带来加法式延迟叠加,还会让后面的单次调用变得更慢。
1.2 总延迟公式:每一环都要单独量化
把一次 Agent 任务拆开,总延迟大致可以写成下面这个公式:
code复制T_total = Σ(T_llm_i) + Σ(T_tool_j) + Σ(T_transfer_k) + Σ(T_retry_wait_l)
其中:
T_llm_i:第 i 次模型调用的推理延迟,包含排队、prefill、生成三个部分。T_tool_j:第 j 次工具执行的延迟,例如查数据库、调 API、执行脚本。T_transfer_k:请求和响应传输带来的延迟,上下文越大,传输越慢。T_retry_wait_l:因为超时、限流、网络抖动产生的重试等待。
这个公式的价值不是用来精确计算,而是用来引导排查方向。只要把一次 Agent 任务走完,把每个环节的时间记录下来,就能看到钱花在了哪里。
1.3 为什么调用次数对总延迟的影响远大于模型速度
举一个直观的例子。假设单次模型调用平均耗时 2 秒,工具执行平均耗时 0.5 秒,不考虑传输和重试,总延迟与调用次数接近线性关系:
| 模型调用次数 | 模型推理总耗时 | 工具执行耗时 | 总延迟估算 |
|---|---|---|---|
| 3 次 | 6 秒 | 1.5 秒 | 7.5 秒 |
| 6 次 | 12 秒 | 3 秒 | 15 秒 |
| 10 次 | 20 秒 | 5 秒 | 25 秒 |
| 20 次 | 40 秒 | 10 秒 | 50 秒 |
假设把单次模型推理速度提升 50%,从 2 秒降到 1.3 秒,6 次调用场景的总延迟会从 15 秒降到约 10.8 秒。但如果把调用次数从 6 次压到 3 次,即使模型速度不变,总延迟也会从 15 秒降到 7.5 秒。
结论很清晰:在 Agent 这种串行调用结构里,减少调用次数通常比提升单次模型速度带来的收益更大,而且更容易实现。
1.4 模型调用次数与质量并不成正比
还有一个常见认知偏差:总觉得"多问模型几次,Agent 会更聪明"。实际上,调用次数增加往往伴随着上下文噪声增加和决策一致性下降。
模型每一步看到的都是之前所有步骤拼接出来的历史。如果中间某一步工具结果冗余、解析失败,或模型产生了错误的中间结论,后续步骤会沿着错误路径继续补问。次数越多,越容易累积误差。换句话说,调得太多不仅慢,还可能不准确。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种"问得太多"的调用模式,是性能黑洞的主要来源
2.1 无效工具调用:工具结果根本没用上
现象:Agent 调用了搜索引擎、数据库或内部 API,把大段结果塞进上下文,但最终回答只用到其中一句话。
原因:模型在规划时倾向于"先查一下再决定"。如果 prompt 里没有约束"能根据已有信息回答就不调工具",模型就会为了保险而过度调用。
示例:
python复制# 错误示范:先查资料,再把完全没用的结果带给模型
search_results = search_tool(query=user_question) # 耗时 2s
full_context = "搜索结果:" + truncate(search_results) # 增加 3000 token
answer = call_llm(full_context + user_question) # 耗时 3s
这段代码的典型问题是:搜索结果里有价值的信息可能只有几十 token,却让整个上下文膨胀了 3000 token。工具调用的 2 秒、传输的额外耗时、模型 prefill 的额外耗时全部被当成"必要成本"。
解决办法是在 prompt 里明确使用条件,同时给 Agent 一个"跳过工具"的选项。更重要的是,Harness 统计里应该能直接看到每个工具返回后被消费了多少 token。如果一个工具结果从未出现在最终回复的依据中,它就有优化空间。
2.2 失败重试循环:一次超时拖出三次重复调用
现象:Agent 调用某个外部工具超时,或者模型接口返回 429、500。Agent 的错误处理逻辑不是直接返回失败,而是继续调模型问"刚才出错了,下一步怎么办"。
原因:很多 Agent 框架把工具失败后的 recovery 也交给模型决策。模型在上下文里看到错误信息后,最稳妥的判断往往是"再试一次"。于是出现"工具超时-模型决策-重试-再超时-再模型决策"的循环。
这种模式的杀伤力在于:一次原本 2 秒的工具调用,最终可能消耗 3 次模型调用、3 次工具调用,总耗时从 2 秒膨胀到 10 秒以上。
排查时看 Harness 输出的 call 列表就能发现:同一个 tool 在连续短时间窗口内出现多次,状态均为 timeout,且中间还穿插着模型调用。对于这类场景,正确的做法不是让模型决定是否重试,而是用代码预设重试策略:幂等操作可以自动重试两次,非幂等操作直接返回失败并进入人工兜底。
2.3 上下文反复被发送:token 膨胀导致每步越来越慢
现象:Agent 的每一步都把完整历史消息重新发送给模型。第 1 步上下文 1000 token,第 5 步已经涨到 6000 token,第 10 步可能接近上下文窗口上限。越往后,单次模型调用越慢。
原因:模型输入包含累积的对话记录、工具返回的完整结果、系统提示词。只要中间任何一环没有做摘要或裁剪,历史就会线性增长。
用一个简化模型说明:
python复制messages = [
system_prompt,
*history, # 每步追加用户消息、工具结果、模型回复
current_request
]
result = call_llm(messages)
history 越长,prefill 阶段的计算量越大。单次调用从 1 秒变成 5 秒,并不是模型变慢了,而是输入变长了。
优化方向:历史消息定期摘要;工具返回只保留结论字段;过去的推理过程压缩为状态文本。不要无脑把全部历史都打进下一步的 prompt。
2.4 规划粒度过细:串联调用放大了线性延迟
现象:一个用三行代码就能完成的任务,Agent 却拆成五个步骤,每步都调模型做规划。
举一个典型例子:把 CSV 文件里的 100 个邮箱去重后发送通知。如果 Agent 选择逐个调用 send_email 工具,那就需要 100 次工具执行,还可能在每次工具执行前后各加一次模型确认,变成 200 次左右的串行调用。
类似的结构性问题在于没有把"批量操作"当成一个整体。模型只盯着单条任务,看不到用 Python 的 set 去重、用循环批量发送才是更合理的实现。
解决办法:在 Agent 的工具集里提供"批量处理"抽象。对于同一类型的数据操作,设计一个接收数组的工具函数,而不是只提供单条操作的原子工具。这一步属于工具设计层面的改进,对调用次数的削减效果非常明显。
3. 用 Harness 思路给 Agent 做一次调用体检
3.1 DeepSeek Harness 给我们的启示:请求级指标比"整体感觉"可靠
DeepSeek Harness 这类评测框架的核心做法,是把一批测试任务统一跑完,记录每个请求的耗时、token 消耗、成功率和输出格式,最后形成指标报告。它不是去替代模型,而是把模型的每一次表现变成可比较、可追踪的记录。
这个思路完全可以应用到 Agent 性能排查上。不要只停留在"Agent 好慢"这个整体感受,而是把 Agent 执行过程中的每一次模型调用都记录下来。只要能看到每个步骤的耗时、token、工具名和状态,瓶颈会自然暴露。
3.2 一个轻量调用记录器,能统计哪些指标
下面是一个可以在 Agent 开发阶段直接使用的 Python 调用记录器。它不考虑复杂框架,只解决一个核心问题:让每次模型调用都有据可查。
python复制import time
import uuid
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class CallRecord:
call_id: str
step: str
tool: Optional[str] # 本步骤关联的工具名,非工具调用可为 None
model: str
start_ts: float
end_ts: float
prompt_tokens: int = 0
completion_tokens: int = 0
status: str = "success" # success / timeout / error / retry
retry_count: int = 0
class HarnessRecorder:
def __init__(self):
self.records: List[CallRecord] = []
def start_call(self, step: str, model: str, tool: Optional[str] = None):
return CallRecord(
call_id=uuid.uuid4().hex[:8],
step=step,
tool=tool,
model=model,
start_ts=time.time(),
end_ts=0.0,
)
def end_call(self, record: CallRecord, status="success",
prompt_tokens=0, completion_tokens=0, retry_count=0):
record.end_ts = time.time()
record.status = status
record.prompt_tokens = prompt_tokens
record.completion_tokens = completion_tokens
record.retry_count = retry_count
self.records.append(record)
def report(self):
if not self.records:
return "no calls"
lines = ["| step | tool | model | duration | prompt_tokens | completion_tokens | status |",
"| --- | --- | --- | --- | --- | --- | --- |"]
for r in self.records:
dur = r.end_ts - r.start_ts
lines.append(
f"| {r.step} | {r.tool or '-'} | {r.model} | {dur:.2f}s "
f"| {r.prompt_tokens} | {r.completion_tokens} | {r.status} |"
)
total_time = sum(r.end_ts - r.start_ts for r in self.records)
total_token = sum(r.prompt_tokens + r.completion_tokens for r in self.records)
lines.append(f"\n总耗时: {total_time:.2f}s")
lines.append(f"模型调用次数: {len(self.records)}")
lines.append(f"总 token 消耗: {total_token}")
return "\n".join(lines)
这个记录器解决的第一步是"可见性"。开发阶段跑完一个任务,直接调用 recorder.report() 就能看到调用次数、耗时和 token 消耗。生产环境可以改成异步上报到监控系统,但思路一致。
3.3 把记录器接到 Agent 循环里
使用方式很简单:把原来直接调用模型的地方包一层计时逻辑,同时记录输入的 token 数。
python复制recorder = HarnessRecorder()
def call_llm(step: str, prompt: str, model: str = "deepseek-chat"):
record = recorder.start_call(step=step, model=model)
# 这里替换为真实的模型调用
time.sleep(2.0) # 模拟推理耗时
# 模拟 token 统计,实际可从模型响应的 usage 字段读取
prompt_tokens = len(prompt) // 3
completion_tokens = 120
recorder.end_call(record, prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens)
return {"text": "dummy response", "usage": prompt_tokens}
实际项目中,prompt_tokens 和 completion_tokens 应该从模型响应里的 usage 字段读取。上面代码只是说明接线方式。
3.4 跑一个 Agent 任务后,从统计表里读出瓶颈
假设一个未优化的工单回复 Agent 跑完一次任务,输出可能像这样:
| step | tool | model | duration | prompt_tokens | completion_tokens | status |
|---|---|---|---|---|---|---|
| classify | - | deepseek-chat | 2.05s | 2100 | 120 | success |
| need_info | - | deepseek-chat | 1.98s | 2200 | 60 | success |
| select_template | - | deepseek-chat | 2.30s | 2400 | 80 | success |
| risk_check | - | deepseek-chat | 2.10s | 2500 | 50 | success |
| generate_reply | - | deepseek-chat | 2.45s | 2600 | 300 | success |
| tone_check | - | deepseek-chat | 2.52s | 2700 | 90 | success |
看到这张表时,不应该急着调模型参数。真正的问题是:一个简单工单回复,为什么要 6 次模型调用?每一次都在做独立的判断,而这些判断完全可以通过一次结构化输出完成。
使用 Harness 记录表排查时,建议按下面的顺序看数据:
- 看模型调用次数:是否大于任务最小必要轮数。
- 看每一步的 prompt_tokens:是否存在连续膨胀。
- 看 tool 列:工具调用是否真正被消费。
- 看 status:是否出现 timeout / retry,重试是否成堆出现。
- 看 completion_tokens:如果模型输出过长但没有产生新的决策价值,就需要约束输出格式。
4. 从"每次都问"改成"能少问就少问":四条可落地策略
4.1 策略一:增加分流层,高置信度请求不必经过大模型
很多 Agent 任务并不需要大模型从零推理。关键词分类、正则判断、名单命中这类问题,用规则就能做到高置信度。把这类请求挡在模型之前,调用次数直接下降。
代码示例:
python复制def route_request(text: str):
# 先走规则,命中高置信度规则就直接返回,不调用模型
if "退款" in text or "refund" in text.lower():
return {"category": "refund", "confidence": 0.95, "need_llm": False}
if "登录失败" in text or "login" in text.lower():
return {"category": "login_issue", "confidence": 0.9, "need_llm": False}
# 低置信度才交给模型
category = call_llm("classify_ticket", f"工单分类:\n{text}")
return {"category": category, "confidence": 0.6, "need_llm": True}
这个策略的关键是分流层要带置信度阈值。规则命中的置信度高于阈值时,模型调用被完全跳过;只有规则模棱两可时才进入模型。真实生产项目中,大部分高频请求往往都属于规则能覆盖的常见场景。
4.2 策略二:把多个判断合并为一次结构化调用
能一次问清的问题,不要拆成五次问。多步判断的常见情况是:分类、判断是否需要补充信息、选择回复模板、检查风险。这些可以压缩成一次调用,用 JSON 结构化输出。
优化前是 5 次调用:
python复制category = call_llm("classify", text)
need_info = call_llm("need_info", text)
template_id = call_llm("select_template", text)
risk = call_llm("risk_check", text)
reply = call_llm("generate_reply", text)
优化后是 1 次调用:
python复制result = call_llm("analyze_ticket", f"""
你是工单分析助手。请分析下面的工单,并输出 JSON:
{{
"category": "...",
"need_more_info": true/false,
"template_id": "...",
"risk_flag": true/false,
"reply_preview": "..."
}}
工单内容:
{text}
""")
parsed = json.loads(result["text"])
合并之后,模型只需要做一次推理,上下文也只有一份工单文本。输出格式被约束成 JSON 后,后续逻辑可以直接用 parsed["risk_flag"] 决定是否走人工审核,不再需要额外询问。
关键点是:结构化输出要配合 JSON Schema 校验。如果模型返回的 JSON 格式不合法,代码要做兜底重新调用一次,这时要记录到 Harness 里,避免"合并不当反而多调一次"。
4.3 策略三:缓存、短路和冗余字段过滤
对相同输入不做重复推理,是减少调用次数最直接的手段。
python复制cache = {}
def get_cache_key(text):
return hash(text.encode("utf-8"))
def analyzed_ticket(text: str):
key = get_cache_key(text)
if key in cache:
return cache[key]
result = call_llm("analyze_ticket", text)
cache[key] = result
return result
缓存策略适合静态稳定的请求,比如同一版本 FAQ 对应的问题、同一批测试样本、固定模板输入。它不适合变化频繁的对话流。
短路则用于安全强约束场景。例如 risk_flag 为 true 或命中敏感词列表时,不再让模型生成回复,直接走人工处理或固定通知模板。
python复制if parsed["risk_flag"]:
return "该工单已转人工审核,原因:命中高风险规则"
这类分支处理不需要模型参与,属于典型的"少问一次"。
4.4 策略四:可并行的调用不要串行
前面讨论的优化方向都是减少调用次数,但有些任务确实需要多次模型调用。此时需要检查这些调用之间是否存在依赖关系。如果彼此独立,就应该并行执行,而不是串行等待。
串行写法:
python复制summary = call_llm("summarize_doc", doc) # 2s
keywords = call_llm("extract_keywords", doc) # 2s
并行写法:
python复制from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as pool:
future_summary = pool.submit(call_llm, "summarize_doc", doc)
future_keywords = pool.submit(call_llm, "extract_keywords", doc)
summary = future_summary.result()
keywords = future_keywords.result()
总延迟从 4 秒降到 2 秒左右。并行化的前提是任务之间没有先后依赖。如果需要根据 summary 再提取 keywords,就不能并行。
需要提醒的是,并行调用会推高瞬时 QPS。如果模型接口有速率限制,要注意控制并发数,否则可能引发 429 错误,反而触发重试循环。
5. 优化前后对比:同样的回复,调用次数从 6 次降到 1 到 2 次
5.1 同一个工单回复任务,优化前的调用链路
定义一个简单场景:收到一条工单,内容是"我买的商品发货太慢,想退款,请问怎么操作?"
优化前的 Agent 按下面 6 步执行,每一步都调用模型:
| 步骤 | 动作 | 调用原因 |
|---|---|---|
| 1 | classify | 判断工单属于哪个类别 |
| 2 | need_info | 判断是否需要向用户追问信息 |
| 3 | select_template | 从模板库中选择回复模板 |
| 4 | risk_check | 检查是否包含风险内容 |
| 5 | generate_reply | 生成最终回复 |
| 6 | tone_check | 再检查一遍语气是否合规 |
每个步骤耗时约 2 秒,总延迟约 12 到 15 秒。用户感知到的就是"转圈半天没有结果"。
5.2 优化后的链路:一次模型调用加规则校验
优化后的 Agent 改为一次调用完成分析和回复生成,返回结构化 JSON:
json复制{
"category": "refund",
"need_more_info": false,
"template_id": "T-REFUND-01",
"risk_flag": false,
"reply_preview": "您好,您可以在订单页面申请退款,平台会在 1-3 个工作日内审核。"
}
代码根据 risk_flag 做规则校验,need_more_info 为 false 时直接使用模板渲染结果。只有 risk_flag 为 true 或 JSON 解析失败等兜底场景才触发第二次模型调用。
这时的典型执行流程:
- 调用一次模型,获得 JSON。
- 代码校验
risk_flag和need_more_info。 - 通过校验,直接返回回复。
5.3 对比结果和冷静看待模拟数据
用模拟数据对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 模型调用次数 | 6 次 | 1 到 2 次 |
| 平均单次调用耗时 | 约 2.2 秒 | 约 2.2 秒 |
| 总延迟估算 | 约 13.2 秒 | 约 4.2 秒 |
| prompt token 消耗估算 | 约 12900 | 约 4600 |
| 高风险人工兜底 | 无明确状态 | 通过 risk_flag 触发 |
需要说明:以上数字是示例,用于展示优化方向和收益量级。真实环境里的延迟、token 消耗和调用次数会受到模型版本、网络环境、工具性能、上下文策略的影响。但结构性的结论是稳定的:调用次数减半,总延迟通常会接近减半。
6. 排查清单和最佳实践:别再让"慢"停留在感觉阶段
6.1 四个常见误区
误区一:一慢就换模型。换模型只能改善单次调用的延迟或质量,对调用次数过多造成的延迟膨胀没有作用。
误区二:只看整体耗时,不看链路。没有调用级指标就无法回答"是模型慢还是问太多"这个问题。
误区三:认为"多问问更准"。多步判断会增加上下文噪声,也会增加错误累积概率。调用次数多不等于答案质量高。
误区四:优化手段没有可观测性。合并调用、加缓存、加并行,都必须用 Harness 或日志再次验证,不能凭感觉认为"肯定更快了"。
6.2 排查清单:遇到 Agent 慢先查这八项
| 检查项 | 检查方式 | 异常信号 | 处理方向 |
|---|---|---|---|
| 应用入口耗时 | 记录请求进入网关的时间 | 入口到第一个模型请求间隔过长 | 检查权限、路由、队列前置逻辑 |
| 模型首 token 延迟 | 模型接口返回首 token 前的耗时 | 首 token 持续很高 | 换网络、换端点或检查排队 |
| 单次模型总耗时 | Harness 记录单次调用起止 | 耗时段内出现重试 | 查看模型服务端状态 |
| 模型调用次数 | Harness 统计 records 数量 | 近似任务出现成倍数调用 | 合并判断,增加分流层 |
| 工具调用结果使用率 | 对比工具返回内容和最终回复依据 | 工具返回大量未使用内容 | 精简工具输出,加条件约束 |
| prompt token 曲线 | 按步骤记录输入 token | token 持续上涨且无摘要 | 历史折叠、去除冗余上下文 |
| 重试次数 | 按 call_id 统计 | 同一工具反复 timeout | 设置自动重试和熔断 |
| 缓存命中率 | 统计 cache hit / miss | 相同请求反复 miss | 检查缓存 key 设计和更新策略 |
在开发环境,这些检查可以通过 HarnessRecorder 的 report 输出完成。在生产环境,建议把这些指标异步写入监控系统,并至少保留最近一个时间窗口的调用级日志。
6.3 生产环境要做的最小治理动作
生产环境的 Agent 性能治理,至少要覆盖三个方面。
第一,调用级日志。每条模型调用要有 step、耗时、token、状态、关联任务 ID。这是排查"慢问题"的基础素材。
第二,慢调用采样。定义"慢"的阈值,例如超过 5 秒的模型调用自动记录完整上下文信息,便于事后还原。
第三,调用量告警。单任务模型调用次数异常增长是 Agent 进入失控循环的典型信号,例如一个平均 3 次的 Agent 在某段时间突然稳定在 15 次,说明上下文、prompt 或工具逻辑里出现了结构性偏差。
另外,不要忽略兜底设计。模型返回宕机、工具持续超时、JSON 解析连续失败时,Agent 必须能在有限重试次数内降级到人工处理。否则性能问题会进一步演化为稳定性问题。
Agent 的性能问题,本质上是一个可以工程化治理的调用链问题。把"问了多少次、每次问了什么、每次花多久"变成可观测指标之后,优化会有明确方向。留给你的下一步,是把这套观测方式接入你自己的 Agent 项目,用一次真实任务跑出第一份调用统计表。慢在哪里,通常一眼就能看出来。
