今天主要讲下这个agent的整体技术架构,以及未来如果要升级到工业级可用差距在哪里,相信你看完会有收获。
先交代一下整体的流程,后面的讨论都建立在这条链路上:
用户 query│▼┌──────────────────────┐│ 意图识别 fast_classify ││ 关键词正则计数 │└──────────────────────┘│├── 单 intent 独占最多 ──→ 直接路由├── 多 intent 平局 ──→ LLM 仲裁(supervisor_cache 5min)└── 零命中 ──→ general_agent(跳过 LLM)│▼┌──────────────────────────────────────────┐│ Worker Agent 处理 ││ general / resume / ppt / research / gaokao │└──────────────────────────────────────────┘│▼┌────────────────────────────────────────────┐│ Reflection 打分 ││ ─ general_agent:跳过,直接返回 ││ ─ 其他 agent: ││ ① 先查 reflection_cache (TTL 10min) ││ ② 未命中则调 LLM 打分 ││ (完整性 / 质量 / 工具使用) ││ ③ score ≥ 7 或 critique==none → 通过 ││ 否则 critique 注入 state,回 worker 重试 ││ (loop < 3) │└────────────────────────────────────────────┘│▼结果流式返回
整条链路意图识别、缓存、自评打分都有。但每一环具体怎么做的,跟工业级方案对比之后,差距就显出来了。下面一个一个说。
一、意图识别
不是简单的「正则命中就走,没命中调 LLM」。我按命中数分了三路。
每个 intent 先维护一份关键词正则列表:
resume:简历 / resume / cv / 求职信 / ATS …ppt:ppt / 幻灯片 / 演示文稿 / slide …research:调研 / 搜集资料 / 市场分析 / 帮我找 …gaokao:高考 / 志愿 / 录取分数线 / 选专业 …
query 进来之后,每个 intent 数一下自己命中了几个关键词,然后看分布:
query│▼┌────────────────┐│ 关键词命中数统计 │└────────────────┘│┌────────────┼────────────┐│ │ │一家独大 两家以上并列 一个都没匹配│ │ │▼ ▼ ▼独占最多 多 intent 平局 零命中直接路由 调 LLM 仲裁 走 general_agentconf = supervisor_cache 跳过 LLM0.6+hits×0.1 TTL 5min
代码大致是这个样子:
INTENT_PATTERNS = {"resume": [r"简历", r"resume", r"cv", r"求职信", r"ATS"],"ppt": [r"ppt", r"幻灯片", r"演示文稿", r"slide"],"research": [r"调研", r"搜集资料", r"市场分析", r"帮我找"],"gaokao": [r"高考", r"志愿", r"录取分数线", r"选专业"],}def fast_classify(query: str):counts = {intent: sum(1 for p in patterns if re.search(p, query))for intent, patterns in INTENT_PATTERNS.items()}hit = {k: v for k, v in counts.items() if v > 0}if not hit:return "general", None # 零命中,直接走 generalmax_hits = max(hit.values())winners = [k for k, v in hit.items() if v == max_hits]if len(winners) == 1:conf = 0.6 + max_hits * 0.1return winners[0], conf # 独占最多,直接路由return supervisor_llm_route(query, winners) # 平局,调 LLM 仲裁
这套设计:
零命中直接跳过 LLM。 绝大多数闲聊、寒暄、跟业务无关的问题都会落到这一路,不会浪费一次 LLM 调用去做无用的分类。 平局才动用 LLM,等于把大模型用在真正需要判断的地方。
工业级是怎么做的
大厂:字节、阿里、美团这些做对话系统的团队——主流做法是单独训一个意图分类模型,通常是 BERT 量级的小模型,fine-tune 过的,几十毫秒就能出结果。
query│▼┌──────────────────┐ ┌────────────────────┐│ BERT 级分类模型 │ ──────→ │ resume 0.87 ││ fine-tune 过 │ │ ppt 0.08 ││ 几十毫秒 │ │ research 0.03 │└──────────────────┘ │ general 0.02 │└────────────────────┘│取 top-1 → 路由到对应 agent
为什么不像我这样用关键词加 LLM 兜底,三个现实原因。
第一,关键词对语言的容错太差。「我不想做简历了」这句话,会命中 resume 关键词,然后被错误路由到简历 agent。同义词、否定句、反讽、口语化表达,正则都搞不定。我那条"零命中走 general 跳过 LLM"的捷径,省的是 token,但代价是把很多本该被理解的复杂表达,也归到了「零命中」里。能列出来的 case 是有限的,用户能说出来的话是无限的。
第二,平局走 LLM 看起来只是兜底,但意图类目变多以后,平局会变成常态。一个 query 同时碰到 resume 和 research 关键词太正常了("帮我调研一下简历润色的服务"),supervisor LLM 会被频繁调用,token 成本上来。
第三,意图类目一旦超过 10 个,正则就开始失控。规则之间会互相冲突,加一条新规则要回归测试一遍老规则,最后没人敢动那个文件。
我现在 5 个 intent 以内还能撑,但这是上限,不是起点。
二、缓存
这一节是我整篇文章里最值得讲的部分,因为我自己在写之前都没意识到——我把缓存放在了错误的位置上。
我现在是怎么做的
整条链路里有两个缓存:
- supervisor_cache:意图识别平局时缓存 LLM 仲裁结果,TTL 5 分钟
- reflection_cache:worker agent 生成 response 之后,进入 reflection 打分之前先查这里,TTL 10 分钟
重点说 reflection_cache。key 是这样构造的:
import hashlibdef reflection_key(model, system_prompt, question, response):raw = f"{model}|{system_prompt}|{question}|{response}"return hashlib.sha256(raw.encode()).hexdigest()
Worker Agent 生成 response│▼构造 sha256(model + system_prompt + question + response)│▼查 reflection_cache (TTL 10min)│┌─┴─────────────────────┐│ │命中 未命中│ │▼ ▼用缓存的 (score, critique) 调 LLM 打分跳过 LLM 打分 返回 SCORE + CRITIQUE实际命中率几乎为零 —— response 每次都不一样
实际命中率几乎为零 —— response 每次都不一样
这个缓存的命中率是零。
原因很简单:key 里包含 response,而 LLM 每次生成的 response 都不一样。哪怕 temperature 调到 0,模型版本、上下文多一个空格都会让输出变。要让两次请求 hash 相同,得是 model、system_prompt、question、response 全都一字不差——线上几乎不会出现。
但更深层的问题不是"命中率低",而是位置错了。
我把缓存放在了 reflection 之前,相当于在缓存"评分结果"。但用户真正想要的不是 score,是 response 本身。缓存应该在 LLM 调用之前就拦截请求,让用户直接拿到历史答案,根本不进 LLM——而不是等 LLM 跑完了,再去缓存它的评分。
工业级是怎么做的:hybrid search + reflection 守门员
正确的形态应该是这样:
query│▼embedding(顺便用来做意图路由)│▼┌─────────────────────────────────┐│ 多路检索(hybrid search) ││ ├─ 向量检索:找语义相似的历史 query ││ ├─ 标量检索:按 intent / 时间窗过滤 ││ └─ 全文检索:BM25 抓精确关键词 ││ ││ 多路结果做 reciprocal rank fusion │└─────────────────────────────────┘│├── 命中(融合分过阈值)──→ 直接返回历史 response,跳过 LLM│└── 未命中 ──→ 调 LLM 生成 ──→ Reflection 打分│├── 分数高 ──→ 写回缓存└── 分数低 ──→ 不入库
这套设计干了两件事:
1. 缓存挪到了 LLM 之前
这是最关键的位置调整。命中之后用户根本不进 LLM,省的不是评分,是整个生成过程。
而且检索不能只靠向量。纯向量召回有死穴——"苹果手机"和"苹果公司"向量很近,会误命中;"简历优化"和"CV 润色"是同一件事但 BM25 抓不到。所以更完善是 hybrid方式的缓存:
- 向量检索:抓语义相似(同义改写、跨语言)
- 标量检索:按 intent、用户画像、时间窗筛掉无关结果
- 全文检索:BM25 兜住向量召不回的精确关键词
- 融合排序:RRF 或加权综合三路结果
这套东西在搜索领域已经跑了十几年(Elasticsearch、Vespa),LLM 时代只是把它接到了 cache 这一层。
2. Reflection 从"裁判"变成"守门员"
我现在的 reflection 是同步的、阻塞用户请求的——每个 response 都要等它打完分才能返回,付了 1-2 秒延迟,但分数永远 8、9 分,谁都拦不住。
放到这套架构里,reflection 的角色彻底变了:
它不再阻塞用户请求(response 已经返回了,打分异步进行) 它变成缓存的守门员(只有打分高的 (query, response) 才入库) 缓存随着时间会越积累越干净,而不是 GPTCache 默认那种"调完 LLM 就全部入库",把低质量答案也缓存进去坑下一个用户
一句话概括这个变化:
reflection 从"每个请求都要交的税",变成"只在写入缓存时付一次的成本"。
跑得越久,赚得越多。这才是 reflection 在生产链路里真正值钱的位置。
怎么衡量这套缓存:三个指标
这是我之前讲漏的另一个点。缓存命中率从来不是一个数字,至少要看三个:
① Chat 命中率(请求级)
chat命中率 = 命中的请求数 / 总请求数衡量"有多少次请求被缓存救了"。产品视角看的就是这个,因为它直接对应"省了多少次 LLM 调用"。
盲点:不区分这次命中省了多少 token。命中 8000 token 前缀和命中 200 token 前缀,chat 命中率算下来都是"命中一次",但省的钱差 40 倍。
② Token 命中率(token 级)
token 命中率 = 命中的 token 数 / 总 token 数衡量"prompt 里有多大比例被复用"。Anthropic 的 prompt caching、OpenAI 的 cached input,账单里给你打折的就是这个数字,不是请求数。
这两个数必须一起看:
光看 chat 命中率,B 是 A 的 16 倍;按 token 命中率算,B 只是 A 的 4 倍。只看任何一个都会做出错误决策。
③ Cache hit latency(命中时的延迟)
如果命中要花 50ms 做向量检索,那命中率 80% 听起来很美,但 P99 延迟可能反而比直接调本地小模型还慢。命中率和命中延迟必须一起看,否则会做出"缓存命中率上去了但用户体感更慢了"的反常识结果。
回头看我的 reflection_cache:它的 chat 命中率是零,所以 token 命中率和命中延迟都没意义——这个缓存现在是挂在那里没在工作。它没有副作用,但也没真正省任何东西。
三、Reflection 打分
worker agent 给出 response 之后,先查 reflection_cache。没命中就用同一个大 LLM 给这份 response 打 0-10 分,评估维度是完整性、质量、工具使用是否正确。
score ≥ 7 或者 critique 是 none → approved,结果返回 score < 7 且当前 loop < 3 → critique 注入 state,回 worker agent 重试
general_agent 比较特殊:它的回答不经过 reflection,直接返回。这是因为 general_agent 处理的本来就是闲聊和兜底场景,没有"工具使用是否正确"这种维度可评。
Worker 生成 response ──→ 同一 LLM 打 0–10 分 ──→ 分数 ≥ 7同一个模型 完整性/质量/工具 返回结果│▼ 分数 < 7│critique 注入 state,回 worker 重试 (loop < 3)
代码逻辑大致是:
def reflect_and_maybe_retry(state, max_loops=3, threshold=7):response = state["response"]if state["agent"] == "general":return response # general_agent 跳过key = reflection_key(model, system_prompt,state["question"], response)cached = reflection_cache.get(key)if cached:score, critique = cached # 几乎不会命中else:score, critique = llm_grade(state) # 真实路径reflection_cache.set(key, (score, critique), ttl=600)if score >= threshold or critique == "none":return response # approvedif state["loop"] < max_loops:state["critique"] = critiquestate["loop"] += 1return goto_worker(state) # 回 worker 重试return response # 兜底
更完善的做法怎么做
这个思路来自 Self-RAG 和 Reflexion 那一系列论文,方向是有共识的。但落到线上推理路径上,问题比我想象中多。
第一个问题是裁判和选手是同一个人。强模型给自己打分天然偏高,日志里几乎每次都是 8、9 分,loop=0 直接通过,reflection 这一环很少真正触发过重试。它形同虚设,但每次请求都要为它付 1-2 秒的延迟。
复杂场景下知道这个问题,所以真正落地 reflection 的团队,要么用更强的模型当 critic(比如主路径用 7B,评估用 70B),要么单独训一个 reward model 专门打分。
主路径模型 ──→ 独立 critic 模型 ──→ reward model较小,例如 7B 更强,例如 70B 专门训出的打分器
关键差别:裁判和选手不是同一个模型
同模型自评在论文里能跑出指标,是因为有标注好的 benchmark;线上没有 ground truth,自己评自己永远是好的。
第二个问题是评估维度太单薄。我现在只看完整性、质量、工具使用对不对。工业场景里要看的远不止这些:事实是否一致、有没有幻觉、有没有有害内容、是否偏离主题、是否符合品牌调性、是否泄露隐私。每一项单拎出来都是一个独立的小模型在做。
第三个问题更根本:Google、Meta 这种量级的团队,把质量优化的钱花在 RLHF 上——也就是在训练阶段就把模型的判断力对齐到人类偏好,而不是在推理时再加一层自我审查。Inference-time reflection 更多用在合成训练数据的环节,而不是线上请求的关键路径。原因很简单:训练阶段的算力是一次性投入,线上的每一毫秒都要乘以 QPS。
——这一切都建立在"reflection 放在用户请求的关键路径上"这个前提下。
如果把它挪到第二节说的位置——异步的、做缓存守门员的——上面三个问题就都不成立了:裁判和选手是同一个又怎样,反正不阻塞用户;评估维度单薄又怎样,至少能筛掉明显的差答案;Inference-time 贵又怎样,命中一次省的远比打分花的多。
位置决定价值。同样一个 reflection,放错了是智商税,放对了是飞轮。
四、链路里还缺哪些
讲完上面三节,我还得诚实地说:整套链路里我目前能讲清楚的也就这三块。剩下的工业级 agent 系统必备的环节,我现在的实现里要么是简化版、要么是没有。列一下:
- 多轮上下文管理:现在整条链路是单轮的。用户说"那帮我改第二段",关键词正则会直接懵。多轮场景下 intent 切换、上下文截断、指代消解,全是坑。
- 工具调用层:worker agent 调"简历分析工具""PPT 生成工具""搜索工具",这些工具本身的超时、重试、熔断、并发控制,我现在基本没做。LLM 挂了用户重问一次,工具挂了请求就吊死。
- 降级路径:LLM 挂了切备用模型、reflection 挂了跳过、cache 挂了 fail-open。现在的图全是 happy path,工业级系统 80% 的代码是错误处理。
- 可观测性:我前面能说"reflection 几乎都是 8、9 分""cache 命中率几乎为零"——是肉眼看日志看出来的,不是从分布、P99、按时间窗趋势里看出来的。没有可观测性,所有改进都凭感觉。
- 安全 / 合规:query 前置过滤(违规、prompt injection、PII)、response 后置过滤(幻觉、敏感信息泄露)。toC 一旦上量这是监管红线,不是"以后再加"能糊弄的。
- 澄清路径:用户问"帮我搞一下",关键词全不沾边,我直接路由到 general_agent。更好的做法是反问澄清:你是想做简历、PPT,还是别的?
- 缓存的失效策略:现在只有 TTL。真实场景还需要版本失效(模型/prompt 升级作废老缓存)、主动失效(发现错答案手动清)、冷热分层、容量上限。
- 重试机制本身:critique 怎么"注入" state、用同一个 prompt 框架重试 3 次的有效性、单请求重试预算的全局控制——这些细节都还没打磨。
- A/B / 灰度:每个改动怎么验证真的更好?没有灰度开关,每次改都是 all-in,要么不敢改要么改完心慌。
按 ROI 排,可观测性应该最先做——没有数据,后面所有改进都是瞎猜。然后才是 hybrid search + reflection 守门员、多轮上下文、工具调用容错。
写在最后
以上是我目前能复盘清楚的部分。这套架构现在跑得动,用户感知不到内部是关键词还是分类模型,感知不到 cache 是放在 LLM 之前还是之后,感知不到 reflection 是真打分还是假打分。但每一环离生产级还差什么,我现在心里有数了。
最大的收获其实不是"知道实际复杂场景怎么做",而是发现自己把对的工具放在了错的位置上——cache 不是没用,是位置错了;reflection 不是没价值,是用法错了。架构里很多看起来在工作的东西,其实都是这样——挂在那里,没出错,但也没真正赚到钱。
后续会继续更新,欢迎关注
夜雨聆风