你有没有过这种经历:自己写的 Agent,跑着跑着就开始死循环、上下文爆炸、还记不住上次说过的话?这些问题的根子,都在一个很多人跳过的前提上------你得先搞清楚 LLM 到底是个什么东西。
前言
现在写 Agent 的人很多,但大多数只是「把 API 包一层 while 循环」,很少去想这个循环里每一步到底在解决什么问题。结果就是:Agent 能跑通 demo,一到真实场景就各种翻车。
这篇文章不堆术语,就干一件事:先花 3 分钟讲清 LLM 的本质,然后基于这个本质,讲一个终端 Agent 里最值得聊的 7 个设计取舍------每一个都是「问题 → 方案 → 为什么这么选」。
如果你也准备自己写 Agent,或者已经在写了但总觉得哪里不对,这篇应该能帮到你。
一、先搞清楚:LLM 到底是个什么东西
这是所有 Agent 问题的起点。不管多强的模型,它的本质都一样:
LLM 是一个函数:f(上下文) → 下一个 token 的概率分布。
它就是个「接龙」机器------你给它一段话,它预测下一个词最可能是什么,然后把结果拼回去,再预测下一个。它没有目标,没有记忆,也不会「想」任何事。
由此能推出三个软肋,这三点是后面所有设计的前提:
- 没有状态:每次调用都是无状态的,上下文里有什么,它就「知道」什么,跨会话一概失忆。
- 上下文有限:窗口是稀缺资源,token 就是钱,塞多了就撑爆。
- 会胡编:它是在「接龙」,不是在「求真」,概率高的不一定是对的。
好,铺垫完毕。下面进入正题:怎么把一个只会接龙的函数,变成能干活的 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 读进来一个大文件,几轮就把窗口撑爆了。
方案:三层防御,从「单条」到「整体」再到「时间」:
- 单条截断:超长结果做 head/tail 分割,保头保尾、丢中间。
- 整体清理:总上下文超过窗口 75%,从最老的工具结果开始,把 output 换成占位符。
- 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 工具,就问你慌不慌。
方案:三层防线。
- 命令分级 :正则粗筛,
rm -rf、sudo、mkfs、curl | sh这类直接判dangerous拒绝执行;rm、mv、git push判moderate给警告。 - Hook 管线 :工具执行前后挂可插拔钩子,pre 阶段可以
block(拦截)或modify(改写输入),post 阶段可以改写输出。 - 读写锁:区分「并发安全」和「需要独占」的工具------读文件可并发(共享锁),写文件必须串行(独占锁),避免多个并发工具同时改一个文件。
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,或者正打算写,希望这篇能帮你少踩几个坑。