跳到主要内容
查看文档索引

Agent 不好用,真不只是 Prompt 太长:我踩过的 10 个坑

Agent 不好用,不一定是模型不够聪明。这篇文章用真实数字讲清上下文、工具、状态、流式输出和观测怎样一起影响结果。

最近一段时间,我一直在优化 Strat Thread 的 Agent。一开始,我只看到两个问题:System Prompt(系统长期给 Agent 的基础指令)太大,Tool 的说明也太多。逐步加载能力虽然让第一轮少读了一些 Token,却经常破坏 Prompt 缓存。

如果只看这两个现象,解法好像很直接:把文字写短,需要时再加载,然后补一个缓存 Key。看起来像是收拾书包,把几本大书拿出去就完事了。

但把最近的故障、Trace(请求行程记录)、Tool 结果和历史改动放在一起后,我发现问题远不止 Prompt。一个真正上线的 Agent,还要决定什么时候加载什么能力,怎样调工具,失败后从哪里继续,文字怎样一点点显示,以及出问题时怎样追查。哪一层没定好规则,最后都可能看起来像“模型不够聪明”。

这篇文章记录我最近实际遇到的十类问题。全文只想留下一个判断:

优化 Agent,不是遇到一个问题就给 Prompt 贴一张新便利贴。要做的,是让上下文、能力、工具、状态和验证记录遵守同一套稳定规则。

这里的“上下文”,就是 Agent 这一轮能看到的信息。Tool 则是它用来查数据、做计算或保存结果的接口。后面看到这两个词,先想成“眼前能读的资料”和“手边能用的工具”就行。

先看三个让我改变判断的数字

一次早期审计中,Strat Thread 行情页面还没有附加用户消息和历史记录,模型输入就已经约 35.5 KB:

组成体积
System Prompt约 5.5 KB
20 个常驻 Tool 定义约 12.8 KB
页面 Markdown、目录、Action 与策略契约约 17.2 KB

另一次近七天的 Tool 统计里,807 次调用有 108 次失败,失败率约 13.4%。其中最多的不是业务失败,而是并行 Tool 混用、非法参数和 Schema 不一致。

策略创建更明显。一个近 30 天样本里,create_strategy 调用了 30 次,只有 5 次单次成功,约 16.7%。模型需要一次提交完整 TypeScript 源码、参数定义、参数值和版本;每修复一个类型错误,又要重新发送整份大参数。

这些数字对应三种不同浪费:

上下文浪费:每轮都让模型读取无关内容
参数浪费:让模型生成本可由 Schema 或服务端直接确定的值
请求浪费:失败、重试和多轮修复继续增加后台实际发出的模型请求

只压缩 Prompt,只能解决第一种的一部分。

一、别把每次教训都塞进 System Prompt

最初的 Prompt 同时承担身份、语言、安全、计划、页面知识、策略规则、上下文压缩和 Tool 使用说明。每出现一个失败,就往通用 Prompt 里补一条规则。短期看很有效,长期就像在门上一直贴便利贴:最后门还在,但已经没人找得到门把手。

我现在把上下文拆成五层:

Foundation
  身份、权限、成功证据、任务循环

Capability index
  Agent 可以加载哪些执行能力

Skill references
  有哪些领域知识、什么时候读取

Dynamic context
  当前页面、当前对象、当前时间和用户输入

Conversation events
  消息、Tool Call、Tool Result、审批与计划状态

Foundation 必须短、稳定、跨任务成立。领域知识不应该因为“有时会用到”就永久住在基础 Prompt 里。中英文也不能同时发送;根据用户语言只加载一套母语化指令。

这不是单纯省 Token。指令越多,模型越难判断当前任务真正重要的约束。OpenAI 当前的模型指南也建议每条指令只陈述一次,只暴露当前相关的 Tool,并保持描述简洁;这类精简仍要在自己的代表性任务上验证,而不是默认“越短越好”。

二、Tool 说明也是模型要读的内容

很多人优化 Prompt 时只数 system/developer 文本,却忘了 Tool 的名称、描述和 JSON Schema 同样要给模型读。Schema 可以先理解成一张表格的填写规则:有哪些格子、每格能填什么、哪些不能空着。

在 Strat Thread 的一次压缩中,31 个静态 Tool 描述从 8,780 B 降到 3,688 B,减少 58%;100 个 Action 描述从 20,627 B 降到 8,228 B,减少约 60%。真正删掉的是重复内容:Tool 名已经表达的动作、Schema 字段已经表达的类型,以及“这是一个已经实现的功能”之类没有决策价值的句子。

Tool 描述只需要帮助模型回答几件事:

  • 什么时候使用;
  • 哪些输入不能从上下文安全推断;
  • 是否有副作用或审批;
  • 能否安全重试;
  • 成功和失败分别意味着什么。

大型目录则不应该首轮全量披露。基础请求保留能力加载器、Skill 查询和必要控制工具;业务 Tool 通过能力目录或 Tool Search 延迟加载。OpenAI 的 Prompt Caching 指南也建议用 defer_loading 降低早期 Tool 定义开销,并将发现到的 Tool 追加到上下文尾部。

这里有一个重要限制:延迟披露不能等于模型完全不知道某项能力存在。首轮仍需一份短索引,告诉模型“可以找到什么”,否则它不会主动查询自己不知道的 Tool。

三、缓存没命中,先看上下文怎么排

渐进加载最容易写成下面这样:

第 1 轮:重新生成 Foundation + Tool A
第 2 轮:重新生成 Foundation + Tool A + Tool B
第 3 轮:重新排序 Foundation + Tool B + Tool A + Tool C

从产品角度看,Agent 在逐步获得能力;从缓存角度看,它每轮都在改写前缀。新 Tool 插入旧 Tool 前面,后面的历史消息、调用和结果就不再位于同一 Token 序列。

正确结构更接近追加日志:

稳定 Foundation [缓存断点]
  + Capability A
  + 用户消息 / Tool Call / Result
  + Capability B
  + 下一轮消息 / Result

OpenAI 当前文档对多轮 Agent 的建议很明确:保持稳定内容在前,新增消息和 Tool 历史只追加;需要时用 additional_tools 增加工具;GPT‑5.6 可以在稳定内容末尾设置显式缓存断点。缓存指标也不能只看 cached_tokens,还要同时看 cache_write_tokens、普通输入、输出、延迟和最终成本。

我现在把这些要求写成缓存合同:

  1. Foundation 文本和 Tool 顺序必须确定;
  2. 时间、页面和会话动态值放在稳定前缀之后;
  3. 已加载能力不在下一条消息中重新排序或无故清空;
  4. Cache Key 描述 Prompt 版本、语言、模式和稳定分片,不描述一次随机请求;
  5. 所有 Agent 改动都做确定性指纹和缓存影响 Review;
  6. 只有明确要求回测时才发送付费 baseline/candidate 请求。

缓存率使用 Token 加权口径:

sum(cached_input_tokens) / sum(input_tokens)

不能把每次请求的百分比直接平均。更完整的缓存原理、OpenLIT Trace 和成本模型,我在上一篇 Prompt Cache 文章里单独展开过。

四、Capability、Skill 和 Tool 必须只有一套关系

架构里曾经同时存在两条披露路径:部分能力由 load_agent_capabilities 直接加载,部分策略 Tool 要先加载两个 Skill 才会出现;Workflow 又是可选能力,但模型需要先会规划,才能决定是否加载 Workflow。

结果是模型在一次策略创建中加载了 strategy_creation,却漏掉 skills。Context 2.0 资源明明存在,它最后却回复“运行时规范资源没有返回”。这不是模型知识不足,而是编排层允许进入一个不完整状态。

我现在更关心三个概念有没有唯一解释:

概念职责
Capability平台可披露的一组执行能力和行为边界
Skill完成领域任务需要参考的版本化知识
Tool真正发生读取、计算或写入的接口

Workflow、询问用户、计划状态和 Skill 查询属于基础控制面。具体策略、行情、回测和系统动作渐进披露。默认 Skill references 必须让模型从第一轮知道资源存在;Skill 正文再按需加载。

更重要的是使用单一 Manifest 生成能力索引、Prompt fragment、Tool group、依赖、生命周期和指纹测试。只要同一能力还需要在六七个枚举和映射里手工同步,Prompt 说可用、Runtime 实际不可用的漂移就会再次发生。

五、全局 Agent 不应该退化成页面宏

早期实现把策略编辑和行情图 Tool 绑定到具体页面。用户不在策略页时,模型看不到编辑工具;不在行情页时,画图能力直接消失。模型于是学会了一个错误因果:必须先跳转,才能解锁 Tool。

但用户对全局 Agent 的预期是:它可以操作整个系统,而不是只操作眼前这张页面。

我最后采用的边界是:

  • 页面只提供当前对象和可视状态,不决定 Tool 是否注册、授权或披露;
  • Tool 参数显式携带 strategyId、版本或 symbol,不隐式依赖当前页面;
  • 操作能在后台完成时先完成;成功后再询问用户是否前往对应页面查看;
  • 必须依赖页面载体的操作,明确返回“尚未执行”和目标页面,获得确认后再导航并重试;
  • 页面中途变化时,旧只读结果作为可恢复 Tool 错误交回 Agent,而不是把整个会话打成 409 失败。

Tool 和页面解耦以后,页面导航从权限机制变成展示策略,系统边界会清楚很多。

六、先修好“表格规则”,再继续写 Prompt

Tool 参数失败时,最常见的反应是给模型补一句“请严格按照参数格式”。但如果 Provider Schema 与 Runtime Schema 本来就不同,再强的文字也只能要求模型猜中两个合同的交集。

历史实现中,operate_market_chart 的运行时是严格判别联合,发给模型的 drawings 却允许任意对象;ask_user 的模型 Schema 也没有表达字段间条件。另一层 Proxy 还可能丢弃 strict,或把 parallel_tool_calls 改回 true

正确顺序应该是:

  1. 从同一份类型源生成 Runtime 校验和 Provider JSON Schema;
  2. 对兼容 Schema 开启 strict: true
  3. 每层对象使用 additionalProperties: false
  4. 可选字段按严格模式要求建模,而不是留下模糊对象;
  5. 任务要求单次调用时设置 parallel_tool_calls: false
  6. 参数失败后只暴露失败的 Tool,强制修复一次,不让模型重新规划整个世界;
  7. 对 Proxy 和模型建立能力矩阵,不能假设 OpenAI-compatible 就等于行为一致。

OpenAI Function Calling 文档说明,严格模式会让函数参数可靠遵循 Schema;关闭并行 Tool Call 可以把单轮限制为零或一次调用。它也明确列出严格 Schema 的结构要求。

数据格式对了,不等于业务也对了。严格模式可以保证 positionPercent 是数字,却不能保证策略代码用对了 Runtime API。这就像表格里确实填了一个电话号码,但不代表它填的是你要找的那个人。两类错误要分开处理。

七、最后一步别让 Tool 携带整个世界

策略创建的低成功率让我重新审视 Tool 粒度。原来的 create_strategy 要求模型一次提交:

  • displayName
  • 几乎永远是 v1initialVersion
  • 完整 TypeScript sourceCode
  • 每个字段已有 default 的 parameterSchema
  • 再重复一份 parameters

这是一笔过大的事务。模型修正一个类型错误时,必须再次生成全部源码和参数,任何字段都可能产生新漂移。

更可靠的设计是:

prepare_strategy_draft
  输入:名称、源码、必要参数定义
  服务端:补默认值、校验、生成指纹
  输出:有 TTL 且绑定用户/会话的 draftId

create_strategy
  输入:draftId
  服务端:核对审批、指纹与幂等后持久化

这条原则可以推广到所有高风险写操作:大对象先准备、校验并持久化为短句柄,最终提交只引用句柄。审批绑定实际内容和指纹,而不是要求模型在审批后重新复制一次内容。

八、对话不只会变长,里面的数据也会过期

长会话的第一个问题是体积。Strat Thread 后来增加了 /new/clear/compact,并在上下文达到窗口阈值后自动触发压缩。压缩不是简单删除消息,还必须保证新任务能接管流、迟到帧不会覆盖新状态、失败时原上下文仍可恢复。

第二个问题更隐蔽:历史内容可能已经过期。K 线、价格、财务指标和事件流如果直接复用旧 Tool Result,Agent 会用完整但过时的数据给出非常自信的结论。

因此时序任务需要独立的新鲜度合同:

读取当前时间
  -> 判断历史结果的 as-of
  -> 重新读取最新数据
  -> 比较时间与闭合状态
  -> 更新会话快照
  -> 再计算和分析

最新读取失败时,只能报告最后可验证的 as-of,不能把依赖失败包装成“数据已经同步”。记忆负责保留过去,新鲜度检查负责决定过去是否仍能代表现在。

九、用户点一次,后台可能请求模型十次

普通聊天可能只调用一次模型;Agent 会经历规划、Tool 选择、结果分析、再次调用和总结。一次用户请求很容易放大成 5~15 次实际模型请求。如果再同时运行多个 Agent 或 Promise.all,短时间的请求数就可能超过 OpenRouter 或上游 Provider 的处理能力。

稳定性不能只靠“失败重试五次”。重试必须知道自己是否安全:

  • 只有尚未产生文本、Tool Call、结束事件或副作用的可重试故障,才能自动重试;
  • 429、部分 5xx 和网络错误读取 Retry-After,否则使用指数退避和 jitter;
  • 400、401、402、403、Schema 错误和上下文超长不能盲目重试;
  • 全局并发限制必须在共享 LLM Gateway,而不是每个 Agent 各自限制;
  • 连接超时、首 Token 超时和总响应超时应分别观测;
  • Provider fallback、Model fallback 和业务侧重试是三层不同机制。

还有一类发布问题来自能力不匹配:应用默认模型名与生产网关精确 ID 不一致,或者 Skill 已描述新 Strategy API,Worker 镜像却仍运行旧版本。这些不应该等用户在线上触发;发布前需要模型目录、Tool 能力、Runtime API 与镜像版本的兼容检查。

十、文字怎么流出来,问题就要怎么被看见

用户看到“卡很久,然后全部文字一起跳出来”,不一定是模型没有流式输出。Strat Thread 的历史链路曾经是:Worker 合并累计快照,Web 每 750ms 读取 Redis;Tool 参数生成期间没有可见文本;终态到达后前端又立即把全文显示出来。

这会形成一种假流式:底层有 delta,产品上却只有心跳和最后一大块文字。并发 Tool 也可能各自完成,却要等最慢调用越过 barrier 后一起显示完成。

更合理的协议需要区分:

output_text.delta
tool_call.in_progress
tool_call.done / failed
output_text.done
response.completed

并给事件递增序号。completed 表示业务终态已经确定,不代表客户端可以跳过尚未呈现的 delta。OpenAI Responses API 也将文本 delta、文本完成和 Response 完成拆成不同事件。

但如果没有观测数据,这些问题仍然会被归结成“模型偶尔卡了”。“可观测”说白了,就是系统出问题时,我们有足够的记录把过程重新拼回来。我最终要求每个会话至少能看到:

  • 根 Agent Run;
  • 每次物理 LLM 请求;
  • 每个 Tool 子 Span;
  • Session ID、模型、实际 Provider、耗时和 Token;
  • Tool 名称、调用 ID、状态和安全错误码;
  • 脱敏、限长后的参数与结果;
  • 重试次数、429/5xx/timeout 比例与请求放大倍数;
  • 缓存读取、写入、普通输入和输出。

正文和 Tool 数据要脱敏、限长并设置保留期。可观测性的目的不是永久保存全部聊天,而是让一次失败可以沿着“用户请求 → 模型 → Tool → 下一次模型”还原。

我现在怎样 Review 一次 Agent 改动

我不再只问“Prompt 有没有写清楚”,而是按下面的顺序检查:

必须回答的问题
Context新内容属于稳定 Foundation、按需知识,还是动态后缀?
Cache是否改写了旧前缀、Tool 顺序、Cache Key 或请求封装?
Capability模型第一轮知道能力存在吗?加载状态和生命周期唯一吗?
ToolRuntime 与 Provider Schema 是否同源、严格、最小化?
State页面变化、压缩、续跑和重试会不会丢失或重复副作用?
Freshness数据是最新事实,还是历史记忆?
Provider模型、Proxy、超时、并发和 fallback 能力是否真的兼容?
UXTool 阶段是否有进度,终态是否跳过了增量?
Evidence能否按 Session 看到实际模型请求、Tool、错误和 Token 成本?

日常改动不需要每次付费回测缓存率。确定性前缀指纹、请求 Envelope 指纹、Schema 审计、类型检查和回归测试可以覆盖大部分结构风险。只有当缓存命中率被明确指定为验收目标时,才用相同 baseline/candidate、相同模型与 Provider、相同工作负载和受控请求数做一次比较。

最后:把“模型问题”还原成系统问题

这十类问题最后都指向同一件事:生产 Agent 不能把正确性寄托在模型每次都能自行补全架构缺口。

Prompt 要有层次,能力要可发现,Tool 要有唯一 Schema,状态要能续跑,写操作要可幂等,数据要带时间边界,流式要有事件语义,失败要能沿 Trace 还原。模型负责在这些边界内做判断,系统负责不让它进入一个无法可靠判断的状态。

所以我现在衡量 Agent 优化,不再只看回答是否更聪明。我会同时看:首轮输入、缓存读写、Tool 成功率、一个任务实际请求了几次模型、重试率、数据是否最新、用户从发出请求到拿到结果要等多久,以及出错后能不能找到原因。

把这些指标放在一起以后,Prompt 才回到它真正的位置:它是 Agent 系统的一层接口,不是用来掩盖其他九层缺陷的补丁。

完 / 继续实践