啊?当你看到这个图之后再打开拼多多,你的推荐列表里一定会出现遐蝶手办
大家好,我是二哥呀。
有小伙伴在群里扔了一段聊天记录,大意是:当你看到这个图之后再打开拼多多,你的推荐列表里一定会出现遐蝶手办。

很多小伙伴都试了一下,还真的是。
我也试了一下,结果如下图所示。

那多模态特征提取到底能不能做到跨 App 的图像识别?协同过滤和深度 CTR 排序在推荐场景里是怎么协作的?Agent 在这类系统里扮演什么角色?
相信很多小伙伴都好奇,正好我也收集了一份拼多多 Agent 岗的面经,附带我自己的答案一并分享给大家。

(全文比较肝,保证大家能学到很多,系好安全带,我们粗粗粗发了~)
content
01、拼多多推荐系统背后用到了哪些 AI 技术?
老王的第一个问题:“用户看了一张图片,打开拼多多就能看到相关商品推荐。背后是什么原理?”
如果真能做到跨 App 的图像识别到推荐,大概是这样这样:

- 设备侧做图像采集或 OCR,提取出视觉特征;
- 云端用多模态模型把图像特征映射到商品类目;
- 然后更新用户的兴趣画像,经过协同过滤加深度 CTR 模型做排序,最终推到首页信息流。
老王追问:“协同过滤和深度 CTR 模型怎么配合?”
“协同过滤解决的是‘和我相似的人还买了什么’,本质是基于用户行为矩阵做相似度计算。深度 CTR 模型(比如 DIN、DIEN)解决的是‘这个用户在当前场景下点击某个商品的概率有多大’,它会考虑用户的实时兴趣、商品特征、上下文特征做精排。协同过滤做召回,CTR 模型做排序,两者是推荐系统的经典搭配。”
“不过坦白说,我个人判断遐蝶手办那个现象大概率是巧合——这本身就是热门品类,新老用户都可能刷到。”
“或者就是纯粹的 bug。”

老王点点头:“行,推荐聊到这。看简历上有个自己搓的 PaiCLI 项目,聊聊 Agent 的内部机制吧。”

02、三层记忆架构为什么这么设计?
老王问:“Agent 的记忆架构是怎么分层的?为什么要这么分?”
我说:“三层。第一层是短期记忆,存当前对话的上下文,包括用户说了什么、助手回了什么、工具调用了什么结果。”
它的底层是一个 LinkedHashMap,按插入顺序维护,有一个 token 预算上限。
当 token 超出预算,自动淘汰最早的条目,淘汰的条目会被暂存到一个压缩摘要队列里,等待后续被 LLM 摘要。

“第二层是长期记忆,跨会话持久化。”
用户偏好、项目事实、关键决策这些跨会话仍然有价值的信息存在这里。底层用 ConcurrentHashMap 维护,同步持久化到磁盘的 JSON 文件里。每次写入都会做内容去重,如果已有条目的内容完全一致,直接跳过。
“第三层是记忆检索层,它不存数据,而是负责从前两层中检索与当前查询最相关的记忆。”
为什么要这么分?
“因为 LLM 的上下文窗口是有限资源。短期记忆解决‘当前对话不丢’的问题,长期记忆解决‘跨会话能想起来’的问题,检索层解决‘该想起哪些’的问题。三层各管各的,互不干扰。”
03、文件作为事实源,为什么不直接用 RAG / 向量数据库存?
老王追问:“长期记忆用 JSON 文件存?为什么不上向量数据库?”
我说:“因为长期记忆存的是精炼的事实,不是大段文本。一条典型的长期记忆就是‘用户偏好用 zsh 而不是 bash’这种。”
数据量小,几十条到几百条,用关键词匹配就够了。

“更关键的是,长期记忆需要高频读写和即时一致性。用户说‘记一下,以后访问语雀优先复用 Chrome 登录态’,我必须立刻存进去,下一轮对话就能用上。”
JSON 文件的写入是同步的,每次 store 操作完成后数据就在磁盘上了。如果用向量数据库,还得等 embedding 计算、索引更新,延迟和复杂度都上来了。
04、文件作为事实源,怎么保证时效性和一致性?
老王问:“那文件存储怎么保证数据不过期、不冲突?”
我说:“时效性靠两个机制。”
第一,写入时即持久化。ConcurrentHashMap 保证并发安全,每次 store 之后立刻把整个条目列表序列化写入磁盘。
第二,检索时有时间衰减。记忆的 timestamp 会参与相关度计算,越旧的记忆在检索排序中越靠后。一条 24 小时前存入的事实,它的分数会自动降到新事实的一半。

“一致性靠去重。长期记忆在 store 时会遍历已有条目,如果新条目的 content 和已有条目完全一致,直接跳过不存。防止 LLM 反复提取同一个事实导致记忆膨胀。”
“另外,长期记忆要区分作用域。”
每条记忆有一个 scope 字段,分 project 和 global 两种。项目级别的事实只在对应项目路径下可见,全局偏好才跨项目共享。检索时会按项目路径做可见性过滤,避免 A 项目的事实污染 B 项目的上下文。
05、文件太大时,索引不能每次读全文,怎么办?
老王点点头,接着问:“记忆文件越来越大,全量加载不现实,怎么办?”
两个层面。
第一,长期记忆本身的体量控制。长期记忆只存精炼事实,不存对话原文和工具输出。工具结果在短期记忆中就被截断到 500 字符以内,不会流入长期记忆。加上内容去重机制,长期记忆条目数通常在几十到几百这个量级,JSON 文件撑死也就几百 KB,全量加载毫无压力。

第二,注入到 LLM 上下文时的预算控制。每次调用 LLM 前,记忆检索层从长期记忆中捞最相关的几条注入到 system prompt 里,注入量有硬上限,context window 的千分之五,最多不超过 5000 token。
这意味着即使长期记忆有 500 条,注入到 LLM 上下文的也就十来条。
06、Agent 是怎么调用工具的?
老王说:“记忆聊清楚了,说说工具调用。Agent 是怎么调工具的?”
我说:“标准的 Function Calling 机制。”
核心循环是一个 while(true) 的 ReAct 循环。
每一轮迭代,Agent 把当前的对话历史连同工具定义列表一起发给 LLM。
LLM 的响应有两种可能:如果它认为需要调工具,就在响应里返回 toolCalls 字段,包含工具名称和 JSON 参数;如果它觉得信息够了,就直接返回自然语言回复。

“收到 toolCalls 后,Agent 把它们封装成调用请求,交给工具注册表去调度执行。”
工具注册表内部维护了一个 ConcurrentHashMap,key 是工具名称,value 是工具定义和执行器。
执行完毕后,结果以 tool 角色的消息追加到对话历史,然后进入下一轮迭代,LLM 根据工具结果继续推理。
“并行调用也支持。如果 LLM 一次返回多个 toolCalls,工具注册表会用一个固定大小的线程池并行执行,最多同时跑 4 个工具。每个工具有独立的超时控制,单个工具 60 秒,整个批次 90 秒。”
还有其他调用方式吗?
老王追问:“除了 Function Calling,还有其他调用方式吗?”
我说:“有。MCP 工具走的是另一条路径。”
MCP 工具不是在 Agent 进程内部执行的,而是通过 JSON-RPC 2.0 协议发送请求到外部的 MCP Server 进程。
Agent 端有一个专门的管理模块统一管理所有 MCP Server 的生命周期,包括启动、初始化握手、工具发现、调用、关闭。

“但从 LLM 的视角看,MCP 工具和内置工具没有区别。”
这个管理模块在启动时会把每个 MCP Server 暴露的工具注册到同一个工具注册表里,工具名格式是 mcp__serverName__toolName。
LLM 不关心工具是内置的还是 MCP 的,它只看工具定义列表里的名称和参数描述。
07、Agent 怎么去判断是否需要调用工具?
老王问:“那判断调不调工具,是 Agent 的逻辑还是 LLM 的逻辑?”
我说:“是 LLM 自己判断的。Agent 不做任何‘要不要调工具’的硬编码判断。”

每一轮 ReAct 循环,Agent 把所有可用的工具定义传给 LLM,LLM 在深度推理之后自己决定:这一轮需要调什么工具、传什么参数,还是直接回复用户。
“Agent 只做两件事:一是把工具的名称、描述和参数 schema 组装成标准的 JSON 格式传给 LLM,让它理解每个工具能做什么;二是忠实执行 LLM 返回的 toolCalls,不加干涉。”
“这就是 Function Calling 的核心理念——工具选择权交给模型。Agent 是执行者,不是决策者。”
08、工具调用失败时,怎么保证 Agent 不死循环或乱回答?
老王紧跟着追问:“万一工具调用失败了呢?Agent 会不会反复重试、陷入死循环?”
我说:“不会,有三道防线。”
第一道是循环预算机制,它在每轮循环开始前做预算检查。
预算机制追踪三个维度:累计 token 消耗、已执行的迭代次数、以及死循环检测。
死循环检测的原理是观察最近几轮是否在重复相同的工具调用模式——如果连续几轮调同一个工具、传同样的参数,直接判定为停滞,强制退出循环并返回错误信息。

“第二道是工具层面的异常处理。”
“工具注册表在执行工具时把所有异常分成三类:策略拒绝(路径越权、命令被禁)返回’策略拒绝’前缀的消息;普通异常返回’工具执行失败’前缀的消息;工具超时返回’工具执行超时,已取消’。”
无论哪种情况,异常都被捕获并转成自然语言字符串,作为 tool 角色的消息返回给 LLM。LLM 看到失败消息后会自行决定:换一种方式再试,还是直接告诉用户操作失败。
“第三道是多 Agent 模式下的 Reviewer 审查。如果用的是多 Agent 协作模式,每个步骤执行完后会经过 Reviewer 审查。Reviewer 判定不合格的步骤最多重试 2 次,超过次数保留当前结果并标记步骤状态,不会无限重试。”
09、有没有做防止反复存储无意义记忆的预防机制?
老王问:“前面提到长期记忆会自动提取事实。那 LLM 提取出一堆垃圾怎么办?”

我说:“做了三层过滤。”
“第一层是意图检测。长期记忆的存储只有两个入口:一是用户通过对话明确说‘记一下’‘记住’‘以后记得’,触发 save_memory 工具;二是上下文压缩时自动提取事实。自动提取的 prompt 里明确告诉 LLM ‘绝对不要提取当前这一轮执行的临时任务、一次性的文件名、模型自己的猜测’。”
“第二层是硬编码过滤。即使 LLM 还是返回了不该存的内容,代码里有两组关键词列表做二次拦截。”
“一组是临时事实前缀——以‘用户想’‘帮我’‘新建’‘删除’‘当前这一轮’等开头的句子直接过滤掉。另一组是推测线索——包含‘可能’‘应该’‘猜测’‘推测’等词的句子也过滤掉。只有通过这两关的、且包含持久事实特征(如‘用户偏好’‘项目’‘技术栈’‘配置’等关键词)的句子才会真正存入长期记忆。”
“第三层是存储时的内容去重。长期记忆在写入前会遍历已有条目,content 完全相同的直接跳过。”
10、MCP 协议解决了什么问题?
老王说:“聊聊 MCP。它解决的核心问题是什么?”
我说:“一句话概括:MCP 解决的是 Agent 工具接入的标准化问题。”
在没有 MCP 之前,每接一个外部工具,就得在 Agent 代码里硬编码一套调用逻辑,HTTP 调一套、CLI 调一套、SDK 调又一套,参数格式、错误处理全不统一。MCP 把工具接入抽象成了一个统一的协议层。

怎么解决的?
“三步。第一步是传输层抽象。MCP 定义了两种标准传输方式:Stdio(标准输入输出,适合本地进程)和 Streamable HTTP(适合远程服务)。Agent 端不用关心底层是进程通信还是网络请求,只要实现同一套传输接口就行。”
“第二步是通信协议。基于 JSON-RPC 2.0,只有三种消息:Request(带 id,需要响应)、Response(带 id,匹配请求)、Notification(无 id,单向通知)。整个生命周期就是 initialize → tools/list → tools/call → close。”
“第三步是工具发现。MCP Server 启动后,Agent 调 tools/list 就能拿到这个 Server 暴露的所有工具的名称、描述和参数 JSON Schema。”
Agent 把这些工具自动注册到自己的工具注册表里,LLM 就能像调内置工具一样调 MCP 工具。
整个过程是动态的,MCP Server 还能发 notifications/tools/list_changed 通知,Agent 会自动重新拉取工具列表并更新注册。
11、Agent 的上下文窗口满了怎么办?
老王问:“上下文窗口快满了,怎么处理?”
我说:”自动压缩。Agent 里有一个对话历史压缩模块,在每轮 ReAct 循环调 LLM 之前做一次检查。它先估算当前对话历史的总 token 数,如果超过压缩阈值就触发压缩。”

“压缩阈值是根据模型的 context window 动态计算的:context window 减去摘要输出预留(最多 20000 token)再减去自动压缩缓冲(最多 13000 token),就是触发阈值。比如 128K 的模型,阈值大概在 95K 左右。”
如何去进行压缩,有哪几种方式?
老王追问:“具体怎么压缩?”
我说:“两套压缩机制并行工作,各管各的。”

“第一套压缩的是 Agent 实际发给 LLM 的消息列表。”
算法是这样的:先找出所有 user 消息的位置索引,保留最近 3 轮的完整消息不动,把 system 之后、分割点之前的所有旧消息喂给 LLM 做摘要。
摘要完成后,重建消息列表:system prompt + 一条包含摘要的 user 消息 + 一条确认了解上下文的 assistant 消息 + 最近 3 轮原始消息。
分割点必须落在 user 消息的边界上,这是为了避免切断 tool_call 和 tool_result 的成对协议。”
“第二套压缩的是短期记忆。”
它用的是 Map-Reduce 模式:先把旧的记忆条目按 5 条一组分片,每片单独让 LLM 摘要,然后再把所有分片摘要合并成一个总摘要。
总摘要以 SUMMARY 类型回注到短期记忆中。同时,压缩过程中还会从旧对话里自动提取持久事实存入长期记忆。
12、RAG 的具体流程
老王若有所思:“行,那说说 RAG。整个 RAG 流程是怎样的?”

我说:“四步。第一步分块。代码分块器对 Java 文件做 AST 解析,用 JavaParser 解析出所有的类声明和方法声明,类级别生成一个 chunk,每个方法单独生成一个 chunk。非 Java 文件按字符数分段,每段不超过 2000 字符。”
“第二步 Embedding。每个 chunk 的文本送给 Embedding 模型生成向量。支持 Ollama 本地模型和 OpenAI 兼容的远程 API。向量和 chunk 元信息一起存入 SQLite 数据库。”
“第三步建索引。SQLite 里有两张核心表:code_chunks 存代码块和向量,code_relations 存代码间的依赖关系(谁调用了谁、谁继承了谁)。索引按项目路径隔离。”
“第四步检索。用户输入一个自然语言查询,检索器同时走两条路:语义检索和关键词检索,结果合并去重后返回 TopK。”
13、Embedding,RAG 如何检索向量?
老王追问:“具体是怎么做混合检索的?”
我说:“语义检索就是标准的向量相似度搜索。”
把用户查询文本送给 Embedding 模型得到查询向量,然后和数据库里所有 chunk 的向量逐一计算余弦相似度,按相似度降序排列取 TopK。
余弦相似度的计算是在内存里做的,因为代码库的 chunk 量级通常是几百到几千,暴力遍历完全撑得住。
“关键词检索不经过 Embedding,直接在 SQLite 里做 LIKE 查询,匹配 chunk 的名称和内容。”
关键词检索命中的结果,基础分设为 0.3,然后根据命中位置叠加额外分数:类名或方法名命中加 0.3,文件路径命中加 0.1,内容命中加 0.1。

“合并的逻辑是:以 filePath#name 为唯一键去重,如果同一个 chunk 在两路检索中都出现了(双重命中),额外加 0.1 的奖励分。”
“然后还有一个代码类型加分:method 类型加 0.15,class 类型加 0.10,因为方法和类比整个文件更直接回答‘怎么实现’的问题。最终按总分降序排列,同一个文件最多保留 2 个结果,防止某个大文件霸占所有位置。”
14、Agent 的 Memory 是如何进行管理的?都存在哪些地方?
老王问:“总结一下,Agent 的记忆都存在哪些地方?”
我说:“四个地方。”

- 对话历史:Agent 发给 LLM 的消息列表,存在 JVM 堆内存里,会话结束即销毁。这是 LLM 真正看到的上下文
- 短期记忆:也在 JVM 堆内存里,但有独立的 token 预算和淘汰策略。它和对话历史并行维护——前者是 LLM 的输入,后者是记忆系统的内部状态
- 长期记忆:存在
~/.paicli/memory/long_term_memory.json文件里,跨会话持久化。启动时全量加载到 ConcurrentHashMap,运行中每次写入同步刷盘 - 向量索引(RAG):代码向量索引,存在
~/.paicli/rag/codebase.db的 SQLite 数据库里。这个不属于对话记忆,是代码库的离线索引
“记忆管理模块是这套系统的门面,统一管理短期记忆、长期记忆、上下文压缩器和记忆检索器。Agent 只和它交互,不直接操作底层的存储。”
15、多 Agent 系统,Agent 之间如何协作?
老王说:“说说多 Agent。系统里 Agent 之间怎么协作?”
我说:“主从架构,一个编排器(Orchestrator)加三种角色的 Sub-agent:规划者(Planner)、执行者(Worker)、审查者(Reviewer)。”

“协作流程分四个阶段。第一阶段,用户提交任务后,编排器把任务交给规划者。规划者的职责是把任务拆解成一组带依赖关系的子步骤,输出一个 JSON 格式的执行计划,每个步骤有 id、描述和依赖列表。”
“第二阶段,编排器解析执行计划,按依赖关系拓扑排序。同一批次内没有依赖关系的步骤可以并行执行。并行执行时,Workers 从一个 BlockingQueue 池子里获取,保证同一个 Worker 不会被两个步骤同时占用。每个并行步骤用独立的 PrintStream 缓冲输出,批次结束后按 step_id 顺序刷新到终端,避免多线程写同一个输出流造成内容交错。”
“第三阶段,每个步骤执行完后交给 Reviewer 审查。Reviewer 输出一个带 approved 字段的 JSON,编排器解析审批结果。未通过的步骤会带上 Reviewer 的反馈重新交给 Worker 执行,最多重试 2 次。”
“第四阶段,所有步骤完成后编排器汇总结果返回给用户。”
多 Agent 之间的上下文怎么管理?
老王追问:“这些 Sub-agent 之间的上下文是共享的还是隔离的?”

我说:“对话历史是隔离的,工具注册表是共享的。”
每个 Sub-agent 有自己独立的对话历史和系统提示词(根据角色不同使用不同的提示词模板),互不干扰。但它们共享同一个工具注册表——这意味着所有 Sub-agent 能调用的工具集合是一样的,只不过规划者和审查者不调工具,只有执行者才调。
“每个步骤执行完后,Worker 会清空自己的对话历史,只保留系统提示词。这是为了让 Worker 处理下一个步骤时不受上一个步骤的残余上下文干扰。”
任务状态如何去进行传递?
“任务状态通过一个不可变的步骤记录对象传递。”
每个步骤有 id、description、dependencies、result 和 status 五个字段。status 在 PENDING → RUNNING → COMPLETED/FAILED 之间流转。
编排器在分配下一批可执行步骤时,会检查每个 PENDING 步骤的 dependencies 是否全部处于 COMPLETED 状态。

“步骤之间的上下文传递靠编排器来组装:它把当前步骤所依赖的已完成步骤的结果拼成一段上下文,注入到 Worker 的任务描述里。这样 Worker 不需要看到所有步骤的历史,只看到和自己直接相关的前置结果。”
16、多 Agent 系统的工作流程和生成效果如何去做量化评估?
老王最后问了一个开放题:“这套多 Agent 系统的效果怎么评估?”

我说:“三个维度。第一是任务完成率。所有步骤都达到 COMPLETED 状态算完成,有 FAILED 步骤算部分完成。这个指标反映的是端到端的可靠性。”
“第二是 Reviewer 通过率和重试率。如果大量步骤需要重试才能通过 Reviewer 审查,说明 Worker 的 prompt 或者工具组合有问题。单步最多重试 2 次是个硬上限,超过就强制保留当前结果。这个指标反映的是单步执行质量。”
“第三是 token 消耗和耗时。每个 Sub-agent 的每轮 LLM 调用都会通过预算机制记录 input token、output token 和 cached input token。编排器可以统计整个任务的总 token 消耗。并行执行的步骤可以通过比较批次耗时和串行估计耗时来评估并行加速比。”
“坦白说,多 Agent 系统的评估在业界还没有统一的标准。我目前主要靠 Reviewer 的审查机制做在线质量把控,离线评估还在探索中。”
PaiCLI 怎么写到简历上?
项目名称:PaiCLI 项目简介:对标 Claude Code 的 Java Agent 命令行工具,支持 ReAct 循环、三层记忆系统、MCP 协议集成、RAG 代码检索、多 Agent 协作 技术栈:Java 17、JSON-RPC 2.0、SQLite、JavaParser、OkHttp
核心职责:
- 设计并实现三层记忆架构(短期/长期/检索),通过 Map-Reduce 摘要和时间衰减评分机制,将上下文压缩率提升至 60% 以上,同时保留关键信息的完整性
- 实现 MCP 协议客户端,支持 Stdio 和 Streamable HTTP 两种传输方式,动态发现并注册外部工具,实现 Agent 工具生态的即插即用
- 构建混合检索 RAG 系统,结合 AST 级代码分块、向量语义检索和关键词精确匹配,双重命中加权合并,代码定位准确率显著优于纯语义检索
- 实现多 Agent 协作框架(Planner/Worker/Reviewer 架构),支持依赖拓扑排序和批次并行执行,通过独立输出缓冲和 Worker 池化避免并发冲突
- 设计循环预算管控机制,集成死循环检测、token 累计追踪和三层工具异常处理,保障 Agent 在长会话中稳定运行不失控
我们下期见。
真诚点赞 诚不我欺
回复