别只会给 LLM 包一层 while 循环:一个 Agent 的 7 个设计取舍

你有没有过这种经历:自己写的 Agent,跑着跑着就开始死循环、上下文爆炸、还记不住上次说过的话?这些问题的根子,都在一个很多人跳过的前提上------你得先搞清楚 LLM 到底是个什么东西。

前言

现在写 Agent 的人很多,但大多数只是「把 API 包一层 while 循环」,很少去想这个循环里每一步到底在解决什么问题。结果就是:Agent 能跑通 demo,一到真实场景就各种翻车。

这篇文章不堆术语,就干一件事:先花 3 分钟讲清 LLM 的本质,然后基于这个本质,讲一个终端 Agent 里最值得聊的 7 个设计取舍------每一个都是「问题 → 方案 → 为什么这么选」。

如果你也准备自己写 Agent,或者已经在写了但总觉得哪里不对,这篇应该能帮到你。


一、先搞清楚:LLM 到底是个什么东西

这是所有 Agent 问题的起点。不管多强的模型,它的本质都一样:

LLM 是一个函数:f(上下文) → 下一个 token 的概率分布。

它就是个「接龙」机器------你给它一段话,它预测下一个词最可能是什么,然后把结果拼回去,再预测下一个。它没有目标,没有记忆,也不会「想」任何事。

由此能推出三个软肋,这三点是后面所有设计的前提:

  1. 没有状态:每次调用都是无状态的,上下文里有什么,它就「知道」什么,跨会话一概失忆。
  2. 上下文有限:窗口是稀缺资源,token 就是钱,塞多了就撑爆。
  3. 会胡编:它是在「接龙」,不是在「求真」,概率高的不一定是对的。

好,铺垫完毕。下面进入正题:怎么把一个只会接龙的函数,变成能干活的 Agent。


二、Agent 的骨架,其实就是一个循环

答案朴素到有点意外:把「调用工具 + 得到结果」塞回上下文,让模型下一轮继续接龙。

ts 复制代码
while (step < MAX_STEPS) {
  const result = streamText({ model, tools, messages, system })
  for await (const part of result.fullStream) {
    if (part.type === 'tool-call')   { /* 模型说:我要调用 read_file */ }
    if (part.type === 'tool-result') { /* 我们真去读了文件,把内容塞回去 */ }
  }
  messages.push(...result.response.messages)
  if (!hasToolCall) break   // 模型不再调工具了,任务结束
}

模型说「我要读文件」,我们就真的去读,把内容塞回上下文;模型看到内容后继续接龙,直到它觉得任务完成、不再调用工具为止。

骨架就这么简单。但真正把它做得「能用」,每一处都是取舍。下面七个,是按「踩坑概率」排的。


三、7 个设计取舍

取舍 ① 防死循环:给一个「没有自我意识」的函数加负反馈

问题:Agent 要循环才能干完活,但模型经常陷入两种死循环------要么对同一个工具同一组参数反复调用(结果每次一样还不肯停),要么在 A、B 两个操作间来回横跳。

方案:给每次工具调用算一个「指纹」(工具名 + 参数哈希),再用一个滑动窗口统计重复次数。更进一步,把「结果哈希」也算上,用来判断它是「在重复」还是「在推进」:

ts 复制代码
const hash = `${toolName}:${sha256(stableStringify(params)).slice(0, 16)}`

检测到重复后分层响应,而不是一刀切:

阈值 动作
≥ 5 次 注入一句提醒:「你可能陷入了重复,换个思路」
≥ 8 次 直接熔断,停止本轮

为什么这么选 :模型是有概率性的,偶尔重复一两次未必是坏循环,硬停会误伤。所以先用「提示」给它一个自我纠偏的机会(软约束),明确到了「无进展」才上硬熔断。这是给无状态函数加负反馈的典型做法------因为它自己根本没有「我在重复」的意识,只能从外部注入。

取舍 ② 上下文精打细算:token 就是钱

问题 :Agent 跑几轮工具调用,上下文就像滚雪球------一个 read_file 读进来一个大文件,几轮就把窗口撑爆了。

方案:三层防御,从「单条」到「整体」再到「时间」:

  1. 单条截断:超长结果做 head/tail 分割,保头保尾、丢中间。
  2. 整体清理:总上下文超过窗口 75%,从最老的工具结果开始,把 output 换成占位符。
  3. TTL 过期:5 分钟前的结果只留头尾,10 分钟前的直接过期。

为什么这么选 :head/tail 截断「保头保尾、丢中间」,是因为工具输出的开头通常是关键结论或路径,结尾通常是错误或状态,中间往往是「过程」,最不重要。还有一个容易忽略的细节------出错的结果不修剪error / failed / denied 这类),因为错误信息恰恰是模型下一轮「换思路」的依据,删了它就真成无头苍蝇了。

顺带一个省事的小技巧:没有 tokenizer 时,直接 字符数 / 4 × 1.2 估算,1.2 是给中文留的安全系数。

取舍 ③ 外置记忆:记忆不能靠上下文

问题:LLM 无状态,跨会话的东西它记不住;塞进上下文又太贵。

方案 :文件即记忆。一条记忆一个 .md 文件,带 frontmatter 描述,另有一个索引文件 MEMORY.md。用的时候先读索引,命中再读具体文件:

md 复制代码
---
name: 用户偏好 TypeScript
description: 用户偏好 TypeScript,不喜欢 Python
type: user
---

为什么这么选:不把记忆全量塞进 prompt,而是「索引 + 按需读取」------索引只是一行行小条目,便宜;真命中某条记忆才去读那个文件,按需付费。还有一个态度上的取舍更关键,系统里明确写了一句:

记忆是线索,不是事实,使用前先验证其准确性。

记忆会过时、会记错,如果模型无条件相信它,就是把「会胡编的模型」架在「可能过时的记忆」上,双重风险。

取舍 ④ 混合检索 RAG:不能只靠向量

问题:纯向量检索会翻车------比如「AK47」这种精确术语,向量可能召回一堆「步枪」相关的泛泛内容,却漏掉精确匹配。

方案 :在同一个 SQLite 里做双路检索 ,一路 sqlite-vec 做向量相似度,一路 FTS5 做关键词匹配,各自归一化后再加权合并:

ts 复制代码
score = vectorScore * 0.7 + keywordScore * 0.3

合并后再过一道 MMR 去重,避免 top5 全是意思相近的重复段落。

为什么这么选 :向量擅长「语义近似」,关键词擅长「精确命中」,两者互补。但互补也意味着冲突(向量分高不代表关键词分高),所以要先各自 min-max 归一化 拉到同一量纲再加权,否则量纲大的一路会直接淹没另一路。MMR 的取舍也很实在:相关度和多样性天生矛盾,你既想要「准」,又不想 5 条结果讲同一件事,λ=0.7 就是取一个偏「准」的平衡。

取舍 ⑤ 子 Agent 编排:别让一个 Agent 干所有事

问题:一个任务拆出多个独立子任务时(比如「对比三个框架」),让主 Agent 自己串行干,上下文会爆炸,而且不同子任务的中间结果会互相「串味」。

方案 :主 Agent 派发子 Agent ,每个子 Agent 有独立的上下文和独立的循环,干完只把结论回传:

ts 复制代码
// 子 Agent 有自己独立的 messages,不污染父 Agent 上下文
const messages = [{ role: 'user', content: request.task }]

但「派发」这个能力必须被约束,否则会无限套娃。这里设了几道闸:深度限制 (子 Agent 不能再 spawn 子 Agent)、并发限制超时熔断 (60 秒用 AbortController 取消,超时了也尽量返回「部分结果」而不是直接失败)。

为什么这么选 :子 Agent 的本质是用「多份独立上下文」换「一份干净上下文」 。代价是 token 总量更大(每个子 Agent 都要完整跑一遍系统提示),收益是任务边界清晰、可并行、可独立超时。所以「要不要派子 Agent」本身就是一个判断:只有任务真能拆成相互独立、且各自耗时较大的子任务时才值得派,否则不如主 Agent 顺次干。

取舍 ⑥ 安全刹车:会胡编的模型拿到了 bash,是风险源

问题 :Agent 会真的去执行 shell 命令、写文件。一个「会胡编的接龙函数」拿到了 bash 工具,就问你慌不慌。

方案:三层防线。

  1. 命令分级 :正则粗筛,rm -rfsudomkfscurl | sh 这类直接判 dangerous 拒绝执行;rmmvgit pushmoderate 给警告。
  2. Hook 管线 :工具执行前后挂可插拔钩子,pre 阶段可以 block(拦截)或 modify(改写输入),post 阶段可以改写输出。
  3. 读写锁:区分「并发安全」和「需要独占」的工具------读文件可并发(共享锁),写文件必须串行(独占锁),避免多个并发工具同时改一个文件。
ts 复制代码
if (isSafe) await acquireConcurrent()   // 共享锁,可并行
else        await acquireExclusive()    // 独占锁,等别人做完

为什么这么选 :安全本质是「信任边界的划分」。正则分类快但糙(会漏报、会误报),所以它只做第一道粗筛,真正的灵活控制交给 Hook 和角色权限。这也是一贯的思路:能用廉价手段挡住 90% 的风险,就别为剩下 10% 付出 100% 的复杂度。

取舍 ⑦ 工具太多怎么办:延迟加载,用到再注册

问题:工具越多,每个工具的 schema(名字、描述、参数定义)都要塞进系统提示词,token 越贵。一个 MCP 服务器动辄几十个工具,全挂上,光提示词就吃掉一大块上下文。

方案 :工具分「常驻」和「延迟」两类。常用工具直接挂上;冷门工具(比如 MCP 的几十个 GitHub 工具)默认不加载,只给模型一个名字清单 。模型需要时,用 tool_search 按名字激活:

ts 复制代码
// 延迟工具只在模型主动搜索时才注册进 prompt
if (tool.shouldDefer && !discoveredTools.has(tool.name)) return false

为什么这么选 :这是「按需付费 」的思路,和记忆的「索引 + 按需读取」是同一套逻辑------默认只暴露最常用的几十个工具,长尾工具留个名字,谁要用谁搜索。代价是多一步 tool_search,换来的是系统提示词大幅瘦身。对上下文窗口这个「稀缺资源」来说,这步很值。


结语

回头看,这 7 个取舍其实都指向同一个原点:

LLM 是一个「预测下一个词」的无状态函数。Agent 的全部工程,就是围绕它的三个软肋做设计。

  • 因为它无状态,所以要外置记忆、做 RAG、用子 Agent 隔离上下文。
  • 因为它上下文有限,所以要截断、清理、TTL 过期、延迟加载工具。
  • 因为它会胡编、会死循环,所以要循环检测、熔断、安全分级。

你看,Agent 看起来花里胡哨,但每一个「工程套路」背后,都是对这三个软肋的应对。搞懂了这个原点,你再去看别人家的 Agent 框架,就会有一种「原来如此」的通透感。

如果你也在写 Agent,或者正打算写,希望这篇能帮你少踩几个坑。

相关推荐
蓝速科技1 小时前
酒店门店 AI 数字人前台场景适配与落地指南
大数据·运维·数据结构·数据库·人工智能·科技
羞儿1 小时前
【读点论文】From Coarse to Fine-Grained Open-Set Recognition
人工智能·深度学习·计算机视觉·细粒度·开集识别
武子康1 小时前
小智发出 abort 后,旧声音为什么还可能继续?
人工智能·llm·agent
悟天特斯1 小时前
AI驱动的楼宇节能:从粗放管控到精准降碳的实践路径
开发语言·人工智能·python·物联网
ss2731 小时前
DeepSeek Harness v0.1.5-rc.1:0.1.5 系列功能冻结,DeepSeek-V41-Flash 成默认模型
人工智能·deepseek·deepseekharness
邵奈一2 小时前
05 从记账本到档案卡:Agent 记忆的存与管
人工智能·大模型·agent
跨境联盟2 小时前
概念解读|家庭健康官是什么?赋能普通家庭的基层健康服务体系落地价值
大数据·人工智能·产品运营·健康管理·精准营养
Mr数据杨2 小时前
二手车价格预测实战案例 从 Kaggle 回归赛题到可落地估价建模
人工智能·数据分析·kaggle竞赛
richard_first2 小时前
Transformer与大语言模型:第18章 向量数据库
数据库·人工智能·自然语言处理·transformer·embedding