整理一组 LLM 应用工程的问题。这些问题的共同特点是:看起来在问技术选型,实际在问你有没有真的把系统跑到生产环境。
先放四个可交互的演示,对应下面第 1、13、9、19 题——这几个问题的答案用图说比用文字说清楚得多:
1. 为什么 RAG 必须得用向量数据库?
不必须。这个前提本身就该被质疑。
10 万条以内的语料,用 numpy 暴力算余弦相似度,延迟在几十毫秒——这个量级根本没有引入向量数据库的必要。一个 np.dot(matrix, query) 就完事了:
# 10 万 × 768 维,单次检索约 30mssims = embeddings @ query_vec # (100000, 768) @ (768,)top_k = np.argpartition(-sims, 20)[:20]向量数据库真正提供的是亿级规模下的 ANN 索引(HNSW、IVF-PQ),把 O(n) 的暴力扫描压到近似 O(log n)。上面第一个演示就是这个复杂度曲线——把规模拖到 10⁴ 以下,两条线几乎贴在一起,ANN 的索引构建和内存开销完全换不回收益。
更关键的是,ANN 是拿召回率换速度的。HNSW 在 recall@10 = 0.95 的设定下,意味着 5% 的情况下漏掉了真正最相关的文档。小语料用暴力检索,反而是 100% 精确。
还有个常被忽略的事实:纯向量检索在专有名词、错别字、精确匹配上打不过 BM25。用户搜 “ORA-01555”,向量模型会把它和一堆语义相近的 Oracle 报错混在一起,而 BM25 能精确命中。所以生产环境的主流做法是混合检索:
BM25 召回 top-50 ┐ ├─→ RRF 融合 ─→ Cross-Encoder 重排 ─→ top-5向量召回 top-50 ┘选型上,如果数据本来就在 PostgreSQL 里,pgvector 通常比引入一个独立向量库更划算——省掉了数据同步、一致性、额外运维这三笔账,而且 ACL 过滤可以直接写 SQL 的 WHERE(见第 13 题)。
2. 记忆写错了,怎么纠正?多 agent 记忆要不要共享?
纠错:不要原地覆盖
原地 UPDATE 是最直觉也最糟的做法——你丢失了「曾经认为是什么」,而排查问题时恰恰需要这个。
正确做法是 append-only + 失效标记(tombstone),每条记忆带完整的 provenance:
{ "id": "mem_8f3a", "content": "项目主库是 MySQL", "source": "session_412 第 7 轮用户发言", "confidence": 0.8, "created_at": "2026-06-14T10:22:00Z", "invalidated_by": "mem_c91d", "invalidated_at": "2026-09-12T09:15:00Z"}冲突仲裁按三级优先:用户显式确认 > 来源可信度 > 时间新近。注意顺序——不能无脑「新的覆盖旧的」,否则模型一次幻觉就能把用户亲口说过的事实冲掉。
高风险记忆(用户身份、权限、关键配置)的写入应该走确认流程,而不是让 agent 自主写。
共享:分层,不要全共享
全共享的真正风险不是冲突,是错误放大——一个 agent 的幻觉会污染所有 agent,而且事后没人知道源头在哪。
┌─────────────────────────────────┐│ 全局事实层 写入需仲裁,只读为主 │ 用户身份、项目约束├─────────────────────────────────┤│ 任务层 同一任务内的 agent 共享 │ 当前任务的中间结论├─────────────────────────────────┤│ 私有层 agent 独占 │ 自己的推理草稿└─────────────────────────────────┘向上提升要带证据和仲裁,向下读取自由。这样单个 agent 出错的影响被限制在它自己的层里。
3. 写好一个 agent skill 重点到底是什么?
重点不是把知识写全,是写清楚触发条件。
一个 skill 写得再详尽,模型在该用的时候没想起来用,等于没写。所以 description 的信息密度比正文重要得多——它是常驻上下文里唯一被看到的部分,必须同时说清楚什么时候用和什么时候明确不用:
---name: db-migrationdescription: > 修改数据库 schema 时使用——加减字段、改索引、改类型。 不要用于纯数据修正(UPDATE/DELETE 业务数据)或只读查询。---第二个重点是渐进披露。description 几十个 token 常驻,正文按需加载,大文件再拆成单独的 reference 文件在需要时才读。把几千 token 的手册硬塞进常驻上下文,是在挤占模型真正干活的空间。
第三个重点:确定性的部分交给脚本,不要让模型推理。校验、格式转换、计算这类有唯一正解的事情,写成脚本让 agent 调用——模型执行脚本的可靠性,远高于模型模拟脚本的逻辑。
一句话总结:skill 设计的是上下文的投递时机,不是文档。
4. Embedding 与生成模型的 Tokenizer 不一致怎么办?
大多数情况下不用管。 两个模型各自 tokenize 各自的输入,embedding 模型输出的是向量,生成模型输入的是文本,中间没有 token 级的接口——不存在「对不上」的问题。
真正会踩的坑是预算估算:切 chunk 的时候按哪个模型的 token 数算?
做法是以字符数或字节数作为中间表示,两端各自换算:
# 切分时:按 embedding 模型的上下文上限卡chunks = split(text, max_tokens=512, tokenizer=embed_tokenizer)
# 拼 prompt 时:按生成模型重新计数budget = 128_000 - len(gen_tokenizer.encode(system_prompt)) - max_output# 留 15~20% 余量,不要卡到刚好中文尤其要留余量。不同 tokenizer 的中文膨胀率能差近一倍——同一段中文,有的模型算 1.1 token/字,有的算 2 token/字。按前者估算然后送进后者,直接爆窗口。
另一个相关的坑:换 embedding 模型必须全量重建索引。不同模型的向量空间没有可比性,混用会让相似度计算完全失去意义。所以索引里应该存模型版本号,启动时校验。
5. 多 Agent 同时执行,具体争抢哪些资源?上下文和状态怎么隔离?
争抢的资源
按踩坑频率排序:
| 资源 | 表现 | 处理 |
|---|---|---|
| API 配额(RPM/TPM) | 429,最常见 | 全局令牌桶 + 指数退避 |
| 文件系统 / git index.lock | 并发写冲突、锁竞争 | 每个 agent 独立 worktree |
| 数据库行锁 | 死锁、超时 | 乐观锁(版本号)+ 重试 |
| 成本预算 | 账单失控 | 全局计费账本,超限熔断 |
| 共享记忆写入 | 覆盖、脏读 | CAS + 版本号 |
| 端口 / GPU 显存 | 启动失败、OOM | 资源池 + 预留 |
| stdout | 日志交错无法阅读 | 带 agent_id 的结构化日志 |
隔离
上下文隔离是默认且必须的——每个 agent 独立的 context window,互不可见。这不只是防污染,也是省钱:共享上下文意味着每个 agent 都要为其他 agent 的对话付 token 费。
状态隔离用消息传递,不要用共享内存。agent 之间通过结构化消息通信,需要共享的状态收敛到一个有明确所有权的地方(比如一个协调者),而不是让多个 agent 直接读写同一块数据。
❌ agent A ──写──→ 共享状态 ←──写── agent B 竞态,且难以追溯✅ agent A ──消息──→ 协调者 ←──消息── agent B 单点写入,有序副作用必须幂等。agent 重试是常态,同一个操作执行两次不能产生两份结果——用幂等键去重(见第 11 题)。
6. 短期记忆和长期记忆在工程中怎么存储、过期、去重?
存储
| 短期 | 长期 | |
|---|---|---|
| 介质 | Redis / 进程内 | 向量库 + 结构化表 |
| 内容 | 原始对话轮次 | 提炼后的事实 |
| 生命周期 | 会话级,TTL 数小时 | 跨会话持久 |
| 检索 | 按 key 直取 | 语义检索 + 打分 |
长期记忆要双写:向量库存语义可检索的表述,结构化表存精确事实(用户 ID、偏好枚举、配置项)。前者用于「想起相关的事」,后者用于「精确查询某个字段」——不要指望向量检索能可靠地答出「用户的邮箱是什么」。
短期记忆窗口满了要摘要压缩而不是直接截断:把最老的 N 轮对话压缩成一段摘要,保留结论丢弃过程。
过期
只靠 TTL 不够,要按分数衰减:
score = importance * exp(-λ * days_since_access) * log(1 + access_count)# score 低于阈值 → 归档(不是删除)重要但很久没访问的记忆不该被清掉(用户的过敏史、关键约束),所以 importance 是乘数而不是加数。归档而非硬删除,留一条回溯的路。
去重
三层,从便宜到贵:
- 精确:内容规范化(去空白、统一大小写、标点)后算 SHA256
- 近似:SimHash / MinHash,抓「改了两个字」的重复
- 语义:embedding 余弦 > 0.92 判定重复
命中重复时的正确动作是 upsert 更新——合并 provenance、刷新访问时间、提升 confidence,而不是新增一条。否则同一个事实会在库里堆成十几条,检索时全部挤进 top-k,把其他记忆挤出去。
7. 怎么让大模型稳定输出 JSON 格式?
优先级非常明确,能用上面的就不要用下面的:
① 约束解码 / Structured Outputs(首选)
在采样阶段就限制 token 空间,让不符合 schema 的 token 概率归零。这是从根本上不可能出错,不是「出错概率低」:
response = client.messages.create( model="claude-sonnet-5", tools=[{ "name": "extract", "input_schema": { "type": "object", "properties": { "sentiment": {"type": "string", "enum": ["正面", "负面", "中性"]}, "score": {"type": "number"} }, "required": ["sentiment", "score"] } }], tool_choice={"type": "tool", "name": "extract"}, messages=[{"role": "user", "content": text}],)data = response.content[0].input # 已经是 dict,无需解析② 工具调用
本质上也是 schema 约束,模型对工具调用格式的训练强度通常高于自由文本里的 JSON。
③ Prompt + 校验重试(兜底)
如果只能走这条,关键是把 validation error 原文喂回去:
for attempt in range(3): raw = llm(prompt) try: return Model.model_validate_json(extract_json(raw)) except ValidationError as e: prompt += f"\n\n上次输出的错误:\n{e}\n请修正后重新输出。"把具体错误告诉模型,比重复一遍「请输出合法 JSON」有效得多。
工程细节
- 降温度到 0~0.2,结构化输出不需要多样性
- 避免深嵌套,超过两层显著掉准确率——扁平化或拆成多次调用
- 避免长自由文本字段,未转义的引号和换行是解析失败的主因
- 流式场景用增量 JSON parser(partial-json)
max_tokens给足,截断产生的半截 JSON 是最难排查的一类失败
8. Agent 死循环防御机制
四层,缺一不可:
① 硬预算(必须有)
@dataclassclass Budget: max_steps: int = 50 max_tokens: int = 500_000 max_seconds: int = 600 max_cost_usd: float = 5.0任何一项超限立即停止。这是最后一道防线,其他机制都失效时靠它兜底。
② 重复检测
工具名 + 参数规范化后哈希,连续 N 次相同即判定循环:
sig = hashlib.sha256( f"{tool_name}:{json.dumps(args, sort_keys=True)}".encode()).hexdigest()if history[-3:] == [sig] * 3: raise LoopDetected(f"连续 3 次相同调用:{tool_name}")注意要规范化参数——{"a":1,"b":2} 和 {"b":2,"a":1} 是同一个调用。
③ 无进展检测
比重复检测更狠一层:调用不重复,但世界状态没变。比如反复用不同关键词搜索却一无所获,或者反复修改同一个文件却改不对。
判据是外部可观测状态的哈希(文件 mtime + 内容哈希、测试通过数、任务清单完成项),连续 N 轮不变即判定卡住。
④ 升级策略(最容易漏)
检测到循环之后干什么?这才是关键:
换方案 → 缩小问题范围 → 请求人工介入 → 放弃并如实报告没有 escalation 的 agent 会在预算耗尽时静默失败,然后返回一个看起来完成了的假结果。这比循环本身危害大得多——循环至少是可见的。
9. Agent 长期记忆召回和普通 RAG 检索有什么区别?
四个本质区别:
① 记忆有时间维度和主体性
RAG 语料是静态、无主的——一份产品文档不属于谁,也不会「过期」(版本更新是另一回事)。记忆则是某个人在某个时刻说的某句话,脱离时间和主体就失去意义。
上面第三个演示就是这个:把 recency 和 importance 权重拖到 0,它就退化成普通 RAG——然后三个月前那条「项目用 MySQL」会盖过上周的「已迁移到 PostgreSQL」,因为前者和「数据库」这个 query 的字面相关性更高。
score = w_rel * relevance + w_rec * exp(-λ*Δt) + w_imp * importance② 记忆会自相矛盾
文档库里两份文档说法不一致,是数据质量问题。记忆里两条记录说法不一致,是正常现象——用户改主意了。所以记忆系统必须有仲裁逻辑(第 2 题),RAG 通常不需要。
③ 召回常常没有显式 query
RAG 是「用户问了问题 → 去检索」。记忆召回很多时候没有人在提问,是根据当前上下文自动触发的联想——用户刚说到部署,系统应该主动想起「这个项目禁止手动上传」。
这意味着记忆召回的 query 是从上下文构造的,而且要控制触发频率,否则每轮都塞一堆无关记忆进去。
④ 记忆是 agent 自己写的
这是最关键的差异。RAG 语料是外部给定的,质量由人把关。记忆是系统自己写进去的——一次幻觉写入会被后续检索反复强化,形成自我确认的错误闭环。
所以记忆必须有 confidence 和 provenance,而 RAG 的 chunk 通常不需要。
10. 大模型接入工具是使用 skill 还是 MCP?
不是二选一,是两个不同层次的东西。
| MCP | Skill | |
|---|---|---|
| 是什么 | 传输协议 | 提示词层的能力包 |
| 解决 | 如何连到外部系统 | 何时用、按什么方法用 |
| 形态 | 独立进程 / 服务 | Markdown + 脚本 |
| 状态 | 可有状态(连接、会话) | 无状态 |
| 上下文成本 | 工具定义常驻,占 token | 渐进加载,description 常驻 |
| 适合 | 数据库、API、SaaS、需鉴权的服务 | 工作流、规范、方法论 |
判断标准很简单:
- 需要连接外部系统、需要鉴权、需要跨进程或长连接 → MCP
- 只是把「怎么做这件事」教给模型,能用现有工具完成 → Skill
典型的组合是两者叠加:MCP 提供访问生产数据库的能力,skill 规定「查生产库前必须先在只读副本上验证 SQL,禁止不带 LIMIT 的全表扫描」。能力和方法论分开。
有个实际的成本差异值得注意:MCP 的工具定义是常驻上下文的,接十个 MCP server 可能就吃掉几千 token;skill 是按需加载的,不用就只花 description 那几十个 token。工具多了之后这个差异很明显。
11. 长任务中途挂了,怎么设计断点续跑?
核心:把任务建模成可持久化的状态机
不要让任务状态只活在内存和对话历史里。显式建模成 DAG,每个节点有明确的输入、输出和状态:
@dataclassclass Step: id: str status: Literal["pending", "running", "done", "failed"] input_hash: str # 输入变了要重跑 output: Any | None attempts: int = 0每步完成立即落 checkpoint,恢复时跳过 done 的步骤直接复用其 output。
三个关键点
① 步骤必须幂等
恢复时不确定上次那步到底做完没有——可能结果已经产生但 checkpoint 没写成功。所以外部副作用要带幂等键:
# 同一个 idempotency_key 重复调用只生效一次create_order(payload, idempotency_key=f"{task_id}:{step_id}")② 区分可重试和不可重试的失败
网络超时、429、5xx → 自动退避重试。 参数错误、权限不足、业务规则拒绝 → 重试一万次也是同样结果,直接升级给人。
把这两类混在一起自动重试,会在必然失败的操作上烧掉整个预算。
③ checkpoint 存摘要,不存全量对话
这是最容易踩的坑。把完整对话历史序列化进 checkpoint,恢复时一次性塞回上下文——直接爆窗口。
checkpoint 里应该存的是结构化的任务状态(哪些步骤完成了、产出是什么、关键决策及其理由),恢复时用这些重建一个精简的上下文,而不是重放原始对话。
12. 大模型流式输出为什么选 SSE 而不是 WebSocket?
因为场景是单向的,WebSocket 的双向能力用不上,却要付出它全部的代价。
SSE 就是一个普通的 HTTP 响应
这一点决定了一切:
GET /v1/chat/streamAccept: text/event-stream
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cache
data: {"delta":"你"}
data: {"delta":"好"}
data: [DONE]既然是普通 HTTP,那么现有的鉴权中间件、CDN、API 网关、访问日志、限流、WAF 全部直接复用。WebSocket 走的是 Upgrade 协议切换,上面这些基本都要重做一遍——鉴权怎么做(URL 带 token 会进日志)、网关支不支持、日志怎么记、限流怎么算。
浏览器原生支持且自动重连
const es = new EventSource('/v1/chat/stream');es.onmessage = e => render(JSON.parse(e.data));// 断线自动重连,并自动带上 Last-Event-ID 请求头续传WebSocket 的重连、心跳(keep-alive ping)、指数退避全要自己写,而且写对不容易。
其他现实因素
- 企业代理和防火墙经常拦 WebSocket,SSE 是标准 HTTP 流量
- HTTP/2 下 SSE 复用连接,没有旧 HTTP/1.1 时代 6 连接上限的问题
- 中断只需要
es.close()或AbortController,不需要额外协议约定
什么时候才该用 WebSocket
真正的双向低延迟场景:实时语音对话(音频流双向)、协同编辑(多客户端互相广播)、需要客户端高频推送状态的场景。
判据很直接:客户端是否需要在流传输过程中持续向服务端发送数据。如果只是「发一个请求、收一串 token」,SSE 就是正确答案。
一个常见误解:SSE 不能发 POST。确实,
EventSource只支持 GET,但用fetch+ReadableStream读text/event-stream就能配合 POST——现在主流 SDK 都是这么做的。
13. RAG 权限管控怎么设计?
铁律:在检索层过滤,不在生成层过滤
❌ 检索全库 → 把所有结果给模型 → prompt 里说"只引用有权限的"✅ 带 ACL 条件检索 → 只有有权限的内容进入 prompt让模型「看到了但别说」是幻想。 提示词注入、模型幻觉、多轮追问都能把内容套出来。唯一可靠的做法是让无权限内容根本不进入上下文。
pre-filter,不是 post-filter
这是本文第二个演示的内容,建议拖一下滑块看效果:
- post-filter:先取全库 top-k,再剔除无权限的 → 剩几条全看运气
- pre-filter:把 ACL 作为检索条件下推,top-k 始终取满
权限比例 30%、top-k = 20 时,post-filter 平均只剩 6 条。权限越稀疏,塌陷越严重——这时候用户体验是「系统查不到东西」,而不是「没权限」,排查起来很费劲。
pgvector 下 pre-filter 就是一个 WHERE:
SELECT chunk_id, content, embedding <=> :q AS distFROM chunksWHERE acl_groups && :user_groups -- 数组相交,先过滤ORDER BY embedding <=> :qLIMIT 20;注意索引设计——要让 ACL 条件能有效过滤,否则查询计划可能退化成全表扫描后再排序。
四个隐蔽的泄漏点
① ACL 快照过期 索引里存的是构建时的权限。用户离职、文档改密级之后,索引没更新 = 越权。要么实时查权限系统,要么保证 ACL 变更立即触发索引更新。
② 引用列表泄漏元数据 正文过滤了,但「参考来源:《2027 年裁员计划.docx》」还在——标题本身就是敏感信息。引用列表要走同一套过滤。
③ 跨用户缓存
用 query 文本做缓存键,A 用户的结果会被 B 用户命中。缓存键必须包含权限维度:hash(query + sorted(user_groups))。
④ Embedding 反演 向量可以被反推出近似原文。如果向量本身是多租户共存的,要么按租户分库/分 namespace,要么确保向量存储层本身也有访问控制。
审计
每次检索记录:谁、什么 query、命中哪些 chunk、用了哪个 ACL 快照版本。出事之后能回答「到底泄漏了什么」——没有这条日志,事故响应时你只能猜。
14. 工具调用的并行化怎么设计?有依赖关系怎么处理?
本质是构建 DAG,不是线性列表
大多数实现把工具调用当成一个队列顺序执行,这是性能浪费的主因。正确的模型是有向无环图:能并行的并行,有依赖的串行。
分三层:
① 模型层:一轮内返回多个 tool_use block,表达「这几件事互不依赖」。
② 依赖判定:靠数据流,不要靠模型自述。如果 B 的参数引用了 A 的输出,就是依赖;否则独立。让模型显式声明依赖关系不可靠——它经常漏标或多标。稳妥做法是模型只声明意图,依赖由执行器从参数引用中静态推导:
# 模型输出里用 ${step_id.field} 表示引用{"id": "s1", "tool": "search_user", "args": {"name": "张三"}},{"id": "s2", "tool": "get_orders", "args": {"uid": "${s1.user_id}"}}, # 依赖 s1{"id": "s3", "tool": "get_holidays", "args": {"year": 2026}}, # 独立
def deps_of(step): return {m.group(1) for m in re.finditer(r'\$\{(\w+)\.', json.dumps(step["args"]))}③ 执行层:拓扑排序分层,同层并发。
async def run_dag(steps): done, results = set(), {} while len(done) < len(steps): ready = [s for s in steps if s["id"] not in done and deps_of(s) <= done] if not ready: raise CircularDependency(...) # 环检测,必须有 outs = await asyncio.gather( *(invoke(resolve(s, results)) for s in ready), return_exceptions=True, ) for s, o in zip(ready, outs): results[s["id"]] = o done.add(s["id"]) return results三个必须处理的约束
并行只对只读工具安全。 两个写操作并行修改同一份资源就是竞态。给工具打标记,readonly 的才进并发批次,写操作串行或加锁:
if any(not TOOLS[s["tool"]].readonly for s in ready): ready = ready[:1] # 有写操作,本层退化成串行部分失败不能全批回滚。 用 return_exceptions=True 收集结果,失败的步骤及其下游标记为 blocked,其他分支继续跑完,最后把「哪些成功、哪些失败、失败原因」一起交给模型决策。直接抛异常中断整批,会浪费掉已经完成的工作。
并发度要限流。 无上限的 gather 会瞬间打爆 API 配额(第 5 题)。加个信号量,通常 3~5 就够——再高的收益被 rate limit 吃掉了。
15. 知识库持续更新,索引、缓存、旧文档召回怎么处理?
三个独立的问题,分开解。
① 增量索引:靠内容哈希,不靠时间戳
时间戳不可靠(文件复制、批量脚本、时区都会污染它)。用内容哈希判定变更,只重算真正变了的 chunk:
for doc in source_docs: h = sha256(normalize(doc.content)) if index.get_hash(doc.id) == h: continue # 没变,跳过 index.delete_chunks(doc_id=doc.id) # 先删旧 chunk index.upsert(chunk(doc), doc_hash=h) # 再写新的注意 delete 必须在 upsert 之前,且要按 doc_id 全删。如果文档从 10 个 chunk 改成 8 个,只做 upsert 会残留 2 个孤儿 chunk——它们指向已经不存在的内容,而且会一直被召回。这是「旧内容阴魂不散」最常见的成因。
② 缓存失效:全局版本号,不要精确失效
精确失效(算出「这次变更影响哪些缓存项」)听起来优雅,实际上算不准,而且算漏了就是脏数据。更稳的做法是缓存键里带一个全局索引版本号:
cache_key = f"{index_version}:{hash(query)}:{hash(sorted(user_groups))}"索引一变,index_version 自增,所有旧缓存自然失效。代价是变更后有一波缓存穿透——比起返回过期答案,这个代价值得付。
(注意这个 key 里也带了权限维度,原因见第 13 题的跨用户缓存泄漏。)
③ 旧文档仍被召回:逐个排查这五个原因
这是最阴的一类问题,因为「删了还能搜到」在日志上完全看不出异常。按发生频率排:
| 原因 | 表现 | 对策 |
|---|---|---|
| 只删正排没删向量 | 原文没了,向量还在,召回到空内容 | 删除走统一入口,双写双删 |
| ANN 软删除未 compact | HNSW/IVF 的删除是打标记,未重建前仍参与召回 | 定期 compact;检索后按 tombstone 二次过滤 |
| 孤儿 chunk | 文档变短,多余 chunk 残留 | 按 doc_id 全删再写(见上) |
| 多副本同步延迟 | 主库删了,只读副本还没同步 | 读写一致性级别;或检索后校验存在性 |
| 缓存未失效 | 索引对的,缓存是旧的 | 版本号失效(见上) |
兜底手段是给每个 chunk 带时间区间,检索时过滤:
WHERE valid_from <= now() AND (valid_to IS NULL OR valid_to > now())这样即使某一层漏删,过期内容也进不了结果集。
另外要有对账任务:定期比对向量库的 ID 集合与源库的 ID 集合,差集就是泄漏点。没有这个对账,你永远不知道索引里躺着多少僵尸数据。
16. Skill 数量过百,如何保证 Agent 调用命中率?
这是检索问题,不是提示词问题。
核心思路一句话:别让模型在 100 个里面挑,让检索器先筛到 10 个以内。 模型在小候选集上的选择准确率远高于大候选集,这个差距比任何提示词技巧都大。
四个手段
① 语义召回:把 skill 的 description + 典型触发场景做成 embedding,按当前上下文召回 top-N,只把这 N 个放进候选。占用不再随 skill 总数增长(见下一题的演示)。
② 领域路由:先定域再选 skill。一级路由只看 10 个领域(数据库 / 部署 / 前端 / 数据分析…),进域后才加载该域的 skill 列表。两跳换来候选集的数量级下降。
③ 负向边界:这是最被低估的一条。description 里必须写清楚什么时候不该用:
description: > 修改数据库 schema 时使用——加减字段、改索引、改类型。 不要用于纯数据修正(UPDATE/DELETE 业务数据),不要用于只读查询。命中率低的真实原因,十有八九是 description 之间语义重叠,而不是描述得不够详细。加细节只会让重叠更严重,划清边界才有效。
④ 评测集:每个 skill 配 10~20 条样本,一半应触发、一半不应触发(尤其是那些容易误触的近邻场景),回归时同时跑命中率和误触率。只看命中率会导致你不断放宽描述,然后误触率失控。
怎么找出重叠的 skill
把所有 description 做 embedding,两两算余弦,> 0.85 的挑出来人工看:
sims = embs @ embs.Tnp.fill_diagonal(sims, 0)for i, j in zip(*np.where(sims > 0.85)): if i < j: print(f"{names[i]} ←→ {names[j]} {sims[i,j]:.3f}")结果通常很难看——100 个 skill 里往往有 20~30 对高度重叠,其中一部分根本就是重复造的。合并比改写更有效。
17. AI 反复修不好一个 Bug 怎么办?
第一件事:停止让它继续改。
反复失败不是「补丁写得不够好」,是问题定位错了。在错误的定位上继续生成补丁,只会让代码里堆满无关的改动,把真正的原因埋得更深。
处理流程
① 回滚到干净状态
git stash # 或 git checkout -- .不要在层层叠加的失败补丁上继续。这些补丁本身已经成为新的干扰变量——后面即使找对了原因,也会被它们掩盖。
② 强制先复现,复现不了不许改
要求产出一个能稳定复现的最小用例。这一步能过滤掉一大半的「反复修不好」——很多时候模型压根没搞清楚 bug 到底是什么现象,就开始改代码了。
③ 要求先给假设和验证方法,再动手
当前假设:连接池在高并发下被耗尽验证方法:在 acquire 前后打印池的活跃连接数,压测到 50 QPS预期观察:如果假设成立,活跃数会顶到 max_size 且不回落验证完再改。这个约束能把「猜着改」变成「查着改」。
④ 换策略,不要换措辞
- 二分定位:
git bisect找引入提交;或注释掉一半代码看现象是否消失 - 加观测而非猜测:在关键路径打日志/断点,让数据说话
- 缩小范围:把可疑模块单独抽出来写个最小脚本复现
- 换模型或换人:不同模型的失败模式不同,卡住时换一个常常立刻见效
最容易被忽略的一点
要求它明确说出「我不知道问题在哪」。
模型的默认行为是持续生成看似合理的补丁——因为「我不知道」在训练数据里出现得少。但一个诚实的「当前证据不足以定位,我需要 X 信息」,比第 5 个错误补丁有价值得多。
把这条写进 skill 或系统提示里:连续两次修复失败后,停止修改,改为输出已排除的可能性和下一步需要的信息。
18. Agent 系统中上下文窗口溢出怎么办?
四层手段,从便宜到贵。
① 预算制:启动时就分配好
不要等到溢出才处理。开局就把窗口切成配额,并规定超额时挤压谁:
BUDGET = { "system": 3_000, # 不可压缩 "skills": 8_000, # 可裁剪(第 19 题) "history": 40_000, # 可压缩 "rag": 25_000, # 可截断 "output": 8_000, # 必须预留}输出预留最容易被忘。 上下文塞到 99% 才发现没地方生成了,模型会直接截断——产出一个半截的 JSON(第 7 题),而且这类失败非常难排查。
② 压缩:对话摘要化 + 工具输出截断
工具返回值是溢出的头号来源,而且经常被忽略。一个 cat 大文件、一次没有 LIMIT 的查询,就能瞬间吃掉几万 token。必须在工具层强制截断,而不是指望模型自觉:
MAX_TOOL_OUT = 4_000def wrap(out: str, ref: str) -> str: if len(out) <= MAX_TOOL_OUT: return out path = save_artifact(out, ref) # 全文落盘 return (out[:MAX_TOOL_OUT] + f"\n\n...已截断,共 {len(out)} 字符。" f"完整内容见 {path},可用 grep/read 按需查询。")关键在最后半句——告诉模型完整内容在哪、怎么按需取,它就不会因为信息缺失而反复重试。
对话历史则做滚动摘要:最老的 N 轮压缩成一段结论,保留决策丢弃过程。
③ 外部化:上下文只留指针
任务状态、中间产物、长文档写进文件或数据库,上下文里只保留路径和摘要。需要细节时再按需读取。这和第 11 题的 checkpoint 设计是同一个思路:上下文是工作台,不是仓库。
④ 分治:子 Agent 各自独立窗口
超长任务拆成子任务,每个子 agent 有自己完整的窗口,主 agent 只接收结构化摘要。这是唯一能真正突破单窗口上限的办法,代价是协调成本和信息损耗——摘要必然丢信息,要设计好摘要的 schema。
兜底:溢出时的降级顺序
真的撞上限了,按固定顺序丢弃,不要随机截断:
工具输出详情 → 老对话轮次 → RAG 结果中排名靠后的 → 非活跃 skill永远不丢:系统指令、当前任务目标、最近 2~3 轮对话。丢掉任务目标的 agent 会开始做无关的事,比报错还糟。
19. 上百个 Skill 塞爆 System Prompt,如何架构重构?
先区分和第 16 题的差别:16 问的是选得准不准,19 问的是放不放得下。 两者都要解,但手段不同。
演示页第四个标签就是这题——拖动 skill 数量,看四种策略的窗口占用:
| 策略 | 常驻占用 | 与 skill 总数的关系 | 代价 |
|---|---|---|---|
| 全量常驻 | N × 1400 | 线性增长,必爆 | — |
| 渐进披露 | N × 45 + 激活项全文 | 线性但斜率降 30× | 几乎没有 |
| 动态注入 | 12 × 45 + 激活项全文 | 与 N 解耦 | 需维护检索层 |
| 子 Agent 分层 | 10 × 45 + 激活项全文 | 与 N 解耦 | 协调 + 消息传递成本 |
按代价从低到高实施
① 渐进披露(先做这个)
只有 name + description 常驻,正文命中后才加载。单个 skill 的常驻成本从约 1400 token 降到约 45 token——一步就能省掉 90% 以上,而且不需要任何额外基础设施。
skills/ db-migration/ SKILL.md # frontmatter 里的 description 常驻 reference.md # 按需读取 validate.py # 脚本,模型调用而非阅读大部分「skill 塞爆窗口」的问题做完这一步就解决了。
② 动态注入
按当前上下文实时检索,只注入最相关的约 12 个 description。占用不再随总数增长——这是第一个真正与 N 解耦的方案。代价是要维护一套检索层,而且检索失误会导致该用的 skill 没被注入(回到第 16 题的命中率问题)。
③ 分层路由
一级 router 只看领域,进域后才加载该域的 skill 清单。用一次额外的路由跳转换取候选集数量级下降。适合 skill 能清晰分域的场景;分不清域的话,路由本身就成了新的准确率瓶颈。
④ 子 Agent 拆分
按领域拆成独立 agent,各自持有自己的 skill 集,主 agent 只负责分发和汇总。最彻底,但引入了协调成本、上下文隔离、消息传递(第 5 题的全套问题)。不到 200+ skill 不建议走这一步。
别忘了治理
技术手段之外,先做一次审计——通常收获比重构还大:
- 使用率统计:三个月没被调用过的 skill,合并或下线
- 重叠合并:用第 16 题的余弦聚类找出高度相似的对
- 长尾处理:调用量占比不到 1% 的 skill,合并进通用 skill 或改成文档
100 个 skill 里往往有 30 个是重复造的,20 个从来没被用过。先删再优化,比直接上架构重构划算得多。
22. Agent 架构设计模式解析
八个常用模式,按耦合度和自由度递增排列。
① Chain(固定管道)
A → B → C → D步骤写死,没有决策点。严格说这不算 agent,是 workflow——但它是基线,能用它解决的就不要往上走。
② Router(分类分发)
┌→ 处理器 A输入 → 分类 ─→ 处理器 B └→ 处理器 C唯一的决策点是「走哪条路」,路径本身是固定的。成本低、可测、失败可定位。生产系统里用得最多的模式,没有之一。
③ ReAct(思考-行动-观察循环)
Thought → Action → Observation ─┐ ↑ │ └────────────────────────────┘最通用的 agent 模式,也是最容易死循环的(第 8 题)。适合路径无法预先枚举的探索型任务。代价是 token 消耗随步数线性增长,且中间任何一步出错都会污染后续推理。
④ Plan-and-Execute(先规划后执行)
先一次性产出完整计划,再逐步执行。相比 ReAct 省 token(不用每步都重新推理全局),但计划一旦制定就僵化——执行中发现计划有问题,要么硬着头皮走完,要么重新规划。
实用的折中是允许重规划:执行器发现偏差时触发 replan,但限制重规划次数(否则又回到死循环)。
⑤ Reflection(自我反思)
产出结果后自评并修正。问题在于模型评自己的作业存在系统性偏差——它倾向于认为自己写的是对的,尤其是错误比较微妙的时候。
⑥ Evaluator-Optimizer(生成器 / 评判器分离)
生成器 → 产出 → 评判器 → 打分 + 反馈 ─┐ ↑ │ └──────────────────────────────────┘比 Reflection 可靠得多,因为评判器有独立的 prompt、独立的上下文、甚至可以用不同的模型。关键是评判器要有客观依据——测试结果、schema 校验、lint 输出,而不是「你觉得写得好不好」。
有客观信号的场景(代码、结构化输出)效果很好;纯主观场景(文案质量)收益有限。
⑦ Orchestrator-Worker(编排者 - 执行者)
主 agent 分解任务,多个 worker 并行执行,主 agent 汇总。唯一能真正突破单窗口上限的模式(第 18 题),代价是协调成本和摘要信息损耗。
适合可并行分解的任务(多文件重构、多来源调研);任务之间有强依赖时并行度上不去,收益还不如串行。
⑧ Debate(多方辩论)
多个 agent 持不同立场互相质疑。学术上效果不错,实践中很少进生产——成本是单 agent 的 N 倍,收益不稳定,而且经常出现「互相说服到一起错」的情况。
怎么选
三个判据:
| 问题 | 是 | 否 |
|---|---|---|
| 路径能预先枚举吗? | Chain / Router | ReAct / Plan-Execute |
| 子任务能并行吗? | Orchestrator-Worker | 串行循环 |
| 错误代价大吗? | Evaluator-Optimizer + 人工确认 | 直接执行 |
实践中最常见的生产组合是 Router + ReAct + Orchestrator-Worker:路由定域,域内用 ReAct 探索,需要并行时拆 worker。
23. 选 workflow 还是 agent?
唯一判据:任务路径能否预先枚举
能枚举 → workflow。不能枚举 → 才考虑 agent。
| Workflow | Agent | |
|---|---|---|
| 路径 | 预先定义 | 运行时决定 |
| 可测试 | ✅ 确定性,可写单测 | ❌ 同样输入可能不同路径 |
| 可观测 | ✅ 每步都在图上 | ⚠️ 要靠 trace 还原 |
| 成本 | 可预估 | 波动大 |
| 失败定位 | 精确到节点 | 要读完整轨迹 |
| 适合 | 已知流程 | 探索型任务 |
agent 的全部优势只有一个:能处理你没预见到的情况。 如果任务根本不存在「没预见到的情况」,agent 的所有代价都是白付。
反模式:用 agent 做本来能写死的流程
这是最常见的浪费。「解析用户上传的 CSV,校验字段,写入数据库」——这个流程完全确定,用 agent 做意味着:
- 成本高一个数量级(每次都要推理)
- bug 不可复现(同样的输入走了不同路径)
- 出错时不知道错在哪(要读完整对话轨迹)
写成 workflow,需要智能的地方(比如字段名模糊匹配)单独调一次 LLM 就够了。
真正的答案:两者不对立
生产系统绝大多数是混合形态——workflow 做主干,agent 做局部节点:
[固定] 接收工单 ↓[固定] 提取结构化信息(单次 LLM 调用) ↓[Router] 判断工单类型 ↓[Agent] ← 只有这一步需要探索:排查问题根因 ↓[固定] 生成回复 ↓[固定] 人工审核 → 发送在确定的骨架里放不确定的关节。 这样既有 workflow 的可观测性和成本可控,又在真正需要灵活性的地方保留了 agent 的能力。
演进策略:先 workflow,再局部替换
不要一开始就上 agent。先写 workflow,遇到分支爆炸时再把那一段换掉:
判断信号:某个节点的 if/else 超过十几个分支,而且还在持续增加——说明这里的路径确实枚举不完,该换 agent 了。
反过来也成立:agent 跑稳定之后,观察它的实际轨迹,如果发现 90% 的请求都走同一条路径,就把那条路径固化成 workflow,只留剩下 10% 给 agent。这能省掉大部分成本。
24. AI 思维链有什么用?
机制:用输出 token 换计算深度
这是最本质的解释。Transformer 单次前向传播的计算深度是固定的——层数是多少就是多少,不会因为问题难就多算几层。
CoT 的作用是把一步映射拆成多步计算:每生成一个 token,就多一次完整的前向传播,中间结果写进上下文,下一步可以基于它继续算。
无 CoT: 问题 ──(固定深度)──→ 答案 深度不够就答错有 CoT: 问题 → 步骤1 → 步骤2 → 步骤3 → 答案 深度 × N所以 CoT 对需要多步串行推理的任务有效(数学、逻辑、多跳问答),对单步映射的任务无效(情感分类、格式转换、实体抽取)——后者本来一次前向就够,加 CoT 纯属浪费 token 和延迟。
四个实际用处
- 提升多步推理准确率 —— 核心价值
- 可解释 —— 能看到它怎么想的(但见下面的局限)
- 可干预 —— 发现中间某步错了,可以直接纠正那一步再继续,而不是重来
- 过程监督信号 —— 训练时对中间步骤打分,比只对最终答案打分有效得多
第 3 点在工程上很有用:agent 的中间推理暴露出来之后,可以在关键决策点插入校验或人工确认。
两个必须知道的局限
① CoT 不等于真实推理过程(忠实性问题)
模型写出来的推理链,未必是真正导致那个答案的计算过程。有研究表明,在 prompt 里植入暗示后,模型会给出被暗示的答案,但推理链里完全不提这个暗示——而是编造一套看起来合理的理由。
这意味着:不能把 CoT 当作审计证据。 「模型解释了为什么这么做」和「模型真的是这么做的」是两回事。在合规、风控这类需要真实归因的场景里,这一点很致命。
② 推理模型已经内化了 CoT
现在的推理模型(reasoning model)在训练时就学会了内部展开推理,再手动加「让我们一步步思考」收益接近零,有时还会干扰它自己的推理模式。
相关的一点:给推理模型施加「简短回答」的约束,可能会压缩它的推理空间从而降低准确率。要短的话,让它推理完再压缩输出,而不是限制它推理。
什么时候不该用
- 简单单步任务 —— 分类、抽取、翻译,纯浪费
- 低延迟场景 —— CoT 显著增加首字延迟和总 token 数
- 需要真实归因的场景 —— 见忠实性问题
- 已经是推理模型 —— 重复施加通常无益
和 ReAct 的关系
顺带厘清一个容易混的概念:
CoT :Thought → Thought → Thought → Answer 纯内部推理ReAct :Thought → Action → Observation → Thought … 推理 + 外部交互ReAct 就是 CoT 加上了工具调用和外部观察。 CoT 只能在模型已有知识内推理,ReAct 能通过 Observation 引入外部事实——这也是它能纠正自己错误假设的原因(第 22 题)。
小结
把这 22 个问题串起来看,重复出现的模式其实只有几个:
- 默认值往往是错的——向量库不是必须的,全共享记忆是有害的,post-filter 是塌陷的,skill 全量常驻是必爆的
- 约束要放在最底层——JSON 靠解码约束而非提示词,权限靠检索过滤而非生成约束,工具输出靠代码层截断而非模型自觉
- 让候选集变小,而不是让模型变聪明——skill 命中率、上下文预算、工具选择,都是这个思路
- 可观测性不是可选项——provenance、审计日志、进展检测、索引对账,出事时全靠它们
- 失败路径比成功路径更需要设计——escalation、幂等、断点续跑、部分失败处理,这些才是生产和 demo 的分界线






