1
入门级 DeepResearch 原理拆解与简单复刻
AI原生应用开发/技术交流
- LLM
- 大模型推理
- 插件应用
3月17日3830看过
一点前言
DeepSearch 是一个引擎,而 DeepResearch 是基于这个引擎的系统。
DeepSearch 的核心理念是“通过在搜索、阅读和推理三个环节中不断循环往复,直到找到最优答案”。
DeepSearch 是一个引擎,而 DeepResearch 是基于这个引擎的系统。
DeepSearch 的核心理念是“通过在搜索、阅读和推理三个环节中不断循环往复,直到找到最优答案”。
-
搜索环节利用搜索引擎探索互联网,广泛获得信息
-
阅读环节则专注于对特定网页进行详尽的分析
-
推理环节负责评估当前的状态,并决定是应该将原始问题拆解为更小的子问题,还是尝试其他的搜索策略。
而 DeepResearch 是在 DeepSearch 的基础上,增加了一个结构化的框架,用于生成长篇的研究报告(或者用于其他)。以生成长篇研究报告为例,工作流程一般从创建目录开始,然后系统性地将 DeepSearch 应用于报告的每一个所需部分:从引言到相关工作、再到方法论,直至最后的结论,最后再用一个大模型整合所有内容,提高最终报告生成的连贯性。
教程偏向 0 基础向,较为冗长,建议有能力的小伙伴在看完流程图和思路后,直接上手操作。
教程部分会带有一定的“为什么”,但更多思考推荐阅读文章:
1. DeepSearch 与 DeepResearch 的设计和实现:https://mp.weixin.qq.com/s/-pPhHDi2nz8hp5R3Lm_mww
教程部分会带有一定的“为什么”,但更多思考推荐阅读文章:
1. DeepSearch 与 DeepResearch 的设计和实现:https://mp.weixin.qq.com/s/-pPhHDi2nz8hp5R3Lm_mww
整体思路
基于 DeepSeek-R1 作为核心模型,整体具体流程图如下

相比于前面说的 搜索 + 推理 + 阅读 + 总结,这里简化为搜索 + 推理 + 总结。
而搜索关键词生成和当前状态推理以及总结部分都选择相同的模型,是因为我们认为模型很多时候的幻觉问题来源于预训练时候缺少的知识(如大模型并不知道最近的 manus 产品,而会认为是 VR 产品)或与当下时空不匹配的知识(如一些产品曾经和现在的价格差异),而不同的大模型其预训练的知识不同,如果选择不同的模型,可能会导致对当前状态的误判,A 模型认为信息充足,因为其预训练知识加上搜索结果已完备,而 B 模型在总结时并没有相关信息,从而容易产生较大幻觉。
而搜索关键词生成和当前状态推理以及总结部分都选择相同的模型,是因为我们认为模型很多时候的幻觉问题来源于预训练时候缺少的知识(如大模型并不知道最近的 manus 产品,而会认为是 VR 产品)或与当下时空不匹配的知识(如一些产品曾经和现在的价格差异),而不同的大模型其预训练的知识不同,如果选择不同的模型,可能会导致对当前状态的误判,A 模型认为信息充足,因为其预训练知识加上搜索结果已完备,而 B 模型在总结时并没有相关信息,从而容易产生较大幻觉。
详细搭建
接下来是详细搭建步骤首先进入 百度智能云千帆 AppBuilder
创建自定义组件 - Search
进入百度智能云千帆 AppBuilder,点击左上角创建组件

根据情况填写具体名称和描述,并且选择空画布

先整体预览一下工作流

这里的话,根据实际情况,我们选择下方的并行实现方式,循环的模式耗时长且冗杂的多。

如果确定了就下方并行模式,则这「代码_3」和「分支器_5」这两个节点实际无用,直接开始节点连接后续节点即可。
「开始」节点添加输入参数 query,变量类型选择 Array<String>

「关键词数」节点,用于计算关键词数量并提取关键词(注:因为在后续生成关键词中设置了最多生成的关键词数为 5,所以这里最多提取 5)
# 输入 query,引用的开始/querydef main(params):query = params['query']search_count = len(query)# 创建一个字典作为输出变量output_object ={"search_count": search_count,"key0": params['query'][0] if len(params['query']) > 0 else "","key1": params['query'][1] if len(params['query']) > 1 else "","key2": params['query'][2] if len(params['query']) > 2 else "","key3": params['query'][3] if len(params['query']) > 3 else "","key4": params['query'][4] if len(params['query']) > 4 else ""}# 返回输出字典类型变量 output_object,包含代码节点所需的输出数据return output_object

「分支器」节点用来判断关键词是否为空,为空则直接跳转到「并行搜索聚合」节点,如果不为空则跳转到「百度AI搜索」节点
「百度 AI 搜索」节点的提示词如下,选择模型 ERNIE-3.5-8K(因为并不需要复杂推理,所以选择速度较快的小模型)
- 不需要回复用户的问题,仅对「联网」中的信息进行总结,总结需要全面,但不要添加自己额外的信息。- 回复请使用清晰、结构化(序号/分段等)的语言,确保可被用户理解和使用。搜索内容:{{query}}

「并行搜索聚合」节点用于将搜索返回的结果进行聚合,并且对引用进行整理,不然会混乱[1][2]的引用资料,具体代码如下
# 输入参数为 search_result_1 到 search_result_5 分别对应 百度AI搜索1-5的text# 输入参数为 search_ref_1 到 search_resf_5 分别对应 百度AI搜索1-5的referencesdef main(params):import re# 初始化聚合内容和全局引用计数aggregated_text = ""aggregated_references = [] # 存放元组: (全局引用编号, 引用字典)global_counter = 1# 循环处理1-5(缺失的结果直接跳过)for i in range(1, 6):result_key = f'search_result_{i}'ref_key = f'search_ref_{i}'text = params.get(result_key)refs = params.get(ref_key)# 如果结果文本为空,则跳过if text is None or text.strip() == "":continue# 为当前结果构建局部编号映射表,防止同一 result 内重复转换相同标识时分配多个新的全局编号local_mapping = {}# 定义替换函数,使用 nonlocal 引用全局计数器def replace_marker(match):nonlocal global_counterlocal_num = int(match.group(1)) # 原局部编号# 如果该局部编号尚未转换,则分配全局编号,并将对应引用加入累计列表(如果有 refs 可用)if local_num not in local_mapping:if refs is not None and local_num <= len(refs):local_mapping[local_num] = global_counterref_item = refs[local_num - 1] # 搜索结果对应的引用:本地1对应列表索引0aggregated_references.append((global_counter, ref_item))global_counter += 1else:# 如果找不到对应的引用信息,也仍然分配全局编号local_mapping[local_num] = global_counterglobal_counter += 1return f'^[{local_mapping[local_num]}]^'# 用正则表达式替换文本中所有符合 ^[数字]^ 格式的引用标记modified_text = re.sub(r'\^\[(\d+)\]\^', replace_marker, text)# 将修改后的文本追加到聚合文本中,每个结果之间换行分隔aggregated_text += modified_text + "\n\n"# 构造参考资料部分文本,每个引用按分配的全局编号排序ref_lines = []for idx, ref in aggregated_references:# 格式可以根据需要进行调整,此处示例:显示全局编号、标题和链接title = ref.get('title', '')doc_id = ref.get('doc_id', '')ref_lines.append(f'【{idx}】 {title} - {doc_id}')aggregated_refs_text = "\n".join(ref_lines)# 最终输出的字符串格式:聚合文本 + 分割标识 + 参考资料部分output_string = aggregated_text.strip() + "\n\n===\n参考资料(含链接)\n" + aggregated_refs_text.strip()output_object = {"output_string": output_string}return output_object

但线路则不需要「文本处理」节点,结束节点也只需要输出「并行搜索聚合」节点的 output_string
完成之后,调试一下,传入参数,如["manus最近新闻","manus进展"],没有异常且正常输出,即可发布组件。
创建 工作流 Agent
接着我们来创建主要的工作流 Agent。退回百度千帆 AppBuilder,点击左上角创建,选择 工作流 Agent 即可进入如下画布

下面我们先预览一下整个工作流

这里「开始」节点不需要调整,默认即可。

「历史处理」节点用于从冗长的对话记录中筛选出必要的对话内容。(这是因为后续在搜索过程中的一些搜索结果会以对话的形式直接传输给用户,为了让用户看到工作流持续在运行,并且检查情况,而这些都会被装入默认的系统参数/chatHistroy参数中,如果后续对话以这个为历史对话记录,则会添加很多冗杂信息,并且过度消耗 token 以及容易爆炸上下文)具体代码如下
# 输入 chat_history 为 系统参数/chatHistroy# 输出为 processed_chat_history,类型 Srtingdef main(params):chat_history = params.get('chat_history', '')# 将对话历史按角色分割segments = []current_role = Nonecurrent_content = []# 按行分割对话历史lines = chat_history.split('\n')for line in lines:# 检测新角色的开始if line.startswith('User:') or line.startswith('Assistant:'):# 如果已经有角色内容,保存之前的内容if current_role is not None:segments.append({'role': current_role,'content': '\n'.join(current_content)})current_content = []# 设置新角色if line.startswith('User:'):current_role = 'User'# 保留"User:"前缀current_content.append(line)else:current_role = 'Assistant'# 保留"Assistant:"前缀current_content.append(line)else:# 继续添加到当前角色的内容current_content.append(line)# 添加最后一个角色的内容if current_role is not None and current_content:segments.append({'role': current_role,'content': '\n'.join(current_content)})# 处理每个Assistant的回复,移除========包围的内容for i, segment in enumerate(segments):if segment['role'] == 'Assistant':# 获取内容content = segment['content']# 使用状态机来处理内容processed_lines = []inside_section = Falselines = content.split('\n')# 第一行是"Assistant:",需要保留if lines and lines[0].startswith('Assistant:'):processed_lines.append(lines[0])lines = lines[1:]for line in lines:if line.strip() == '========':# 切换状态inside_section = not inside_sectionelif not inside_section:# 只有不在========部分内的内容才保留processed_lines.append(line)# 更新处理后的内容segments[i]['content'] = '\n'.join(processed_lines)# 重新组合对话历史processed_chat_history = '\n'.join(segment['content'] for segment in segments)return {'processed_chat_history': processed_chat_history}

「meta_data」节点用于装入一些基础信息,包括基本环境信息,这里为当前日期。(该节点看似很简单,只给大模型提供当前时间,但实际上如果缺少,效果差异很大,大模型会误判当前时间从而进行各种时空不匹配的信息推理)具体代码如下
# 不需要输入# 输出为 current_timeimport datetimedef main(params):current_time = datetime.datetime.now().strftime('%Y-%m-%d')# 创建一个字典作为输出变量output_object ={# 引用节点定义的 city 变量"current_time": f'当前时间:{current_time}'}# 返回输出字典类型变量 output_object,包含代码节点所需的输出数据return output_object

「深度提问」节点的作用为让大模型反问用户,从而获得更全面的信息补充。而这功能的来源有以下两点
-
我们认为在用户向 Agent 提问时,大部分情况下,用户的信息所包含的先验知识密度是高于大模型的,因此尽可能补充先验知识是利于大模型最终输出优质结果的
-
在绝大部分情况下,想要模型有更好的结果需要详细的信息和用户指引,但让用户在第一次输入时提供所有信息并不自然。
因此这个节点可以让模型和用户进行第一次初步交互,确定需要研究和搜索的具体方向,以及对模型的幻觉进行极其重要的第一次纠正。具体提示词如下
你是一个专业的深度搜索助手,负责帮助用户明确和细化搜索需求,以便进行更精确的深度信息检索。# 任务- 理解用户的初始搜索意图- 通过对话引导用户详细说明其信息需求- 通过对话询问确定搜索的具体范围、重点和期望结果- 对于你不清楚的一些内容,可以先向用户询问# 环境信息{{meta_info}}# 历史对话记录{{chat_history}}# 用户当前提问{{query}}# 开始向用户提问

接下来是一个循环体,用于让大模型循环查看现在搜到的资料是否足够回答用户问题,如果不够,继续进行搜索,因此设置上属于条件循环。

这里需要配合我们刚刚创建的 search 组件用于搜索,而 R1 模型负责判断信息是否充足,如果不够则输出需要搜索的关键词,而输出的关键词交给 search 组件进行搜索和总结。

「index」节点用于搜索过程中给用户的信息展示
def main(params):# 创建一个字典作为输出变量output_object ={# 引用节点定义的 city 变量"index": params['index']+1}# 返回输出字典类型变量 output_object,包含代码节点所需的输出数据return output_object

「分支器_1」节点用于分支出第一次搜索、后续循环搜索并限制最大搜索轮次。

如果循环次数 = 5 则跳出循环,也就是最大搜索 4 次
「首次输出大模型」和「后续深度输出大模型」节点的功能几乎相同,分别是判断当前已知信息是否足够以及在不足的情况输出搜索关键词。
但二者提示词还是有些许区别,因为对于问题处理来说,人类的搜索过程往往是一开始不清楚具体信息,从而进行比较宽泛的搜索,如“manus 是什么”,接着才是深度的具体检索“manus ai 产品的实现细节”。
因此首次搜索的大模型在提示词方面会告知需要一定的定义搜索,而后续深度搜索大模型在提示词方面则会告知需要使用一定的搜索策略(如识别信息缺口、多维度探索、多语种搜索等)
二者具体提示词如下
你是一个联网信息搜索专家,你需要根据用户的问题生成有效的搜索关键词。# 任务- 分析用户的问题,生成适合第一轮搜索的关键词- 第一轮搜索应该以宽泛的理解为主,帮助建立基本认知- 如果用户问题非常明确且「当前已知资料」已经足够回答,返回"无需检索"- 如果用户问题模糊或缺乏上下文(如"搜一下Manus情况"),应该生成包含基础定义的关键词(如"Manus 是什么")- 除非你特别了解当前用户提问,不然首次搜索通常只需要1-3个关键词,避免过于具体和复杂而造成过多冗余的搜索- 每个关键词应该独立完整,包含必要的主语和宾语,避免使用代词- 最多生成{{max_search_words}}个关键词- 关键词之间不应有逻辑依赖或指代关系- 输出的关键词放在 ```txt ```代码块中,关键词之间用 ; 分割# 搜索策略- 优先理解用户真正想知道的是什么- 从宽泛到具体,先搜索基本概念和背景- 对于模糊的问题,生成能帮助理解基本情况的关键词- 避免过早进入细节或专业术语# 历史对话assistant:{{llmquery}}user:{{user_answer}}{{chat_history}}# 用户问题:{{question}}# 当前已知资料{{reference}}# 当前环境信息{{meta_info}}当前已进行{{index}}次搜索# 你的回答:

你是一个高级信息检索专家,负责基于当前已知资料和用户原始提问进行深度信息挖掘。# 任务- 分析「当前已知资料」和「用户原始问题」之间的信息差距,判断「当前已知资料」是否足以回答用户问题- 如果「当前已知资料」不足以回答用户问题,则需确定进一步探索的具体方向和细节,生成更精确、更有针对性的搜索关键词,务必确保每个关键词的精简和独立性- 输出的每个关键词都应该要具体到可以用于独立检索,要包括完整的主语和宾语,避免歧义和使用代词,关键词之间不能有指代关系- 如果「当前已知资料」已经满足用户需求,返回"无需检索"- 如果发现信息冲突或需要验证的内容,生成用于交叉验证的关键词- 善用深度搜索策略,包括但不限于识别信息缺口、多维度探索等- 输出2-{{max_search_words}}个关键词,关键词之间用 ; 分割- 最终输出的关键词放在 ``txt ``代码块中# 用户原始问题:{{question}}# 当前已知资料:{{reference}}# 历史对话:assistant:{{llmquery}}user:{{user_answer}}{{chat_history}}# 当前环境信息:{{meta_info}}# 你的回答:

「首次提取关键词」和「后续提取关键词」,这两个节点功能一样,从 LLM 的输出中提取出搜索关键词,代码相同,配置如下
import redef main(params):# 获取LLM的输出llm_output = params.get('llm_output', '')# 初始化结果变量keywords = []# 检查是否包含"无需检索"if "无需检索" in llm_output:return {"output": "无需检索","keywords": []}# 尝试使用正则表达式提取代码块中的内容code_block_pattern = r"```(?:txt)?\s*([\s\S]*?)```"code_matches = re.findall(code_block_pattern, llm_output)if code_matches:# 使用代码块中的内容content = code_matches[0].strip()# 分割关键词(用分号分隔)if ";" in content:keywords = [kw.strip() for kw in content.split(";") if kw.strip()]else:# 如果没有分号,可能整个内容就是一个关键词if content.strip():keywords = [content.strip()]else:# 如果没有找到代码块,尝试直接从文本中提取关键词# 寻找可能包含关键词的行lines = llm_output.split('\n')for line in lines:line = line.strip()# 跳过明显不是关键词的行if line and not line.startswith('#') and not line.startswith('-') and ":" not in line[:10]:# 检查是否有分号分隔的内容if ";" in line:# 可能是分号分隔的关键词列表kw_candidates = [kw.strip() for kw in line.split(";") if kw.strip()]if kw_candidates:keywords = kw_candidatesbreak# 清理关键词(去除可能的额外标点符号或引号)cleaned_keywords = []for kw in keywords:kw = kw.strip('\'".,;:()[]{}')if kw:cleaned_keywords.append(kw)# 构建输出if cleaned_keywords:output_keywords = ";".join(cleaned_keywords)else:# 如果没有找到关键词且没有明确说无需检索,返回一个默认值output_keywords = "未找到有效的搜索关键词"return {"output": output_keywords,"keywords": cleaned_keywords}

「消息_1」和「消息_3」节点功能也相同,为向用户展示当前进度

「分支器」和「分支器_2」节点功能相同,判断 LLM 输出是否包含 无需检索,如果包含,则表示当前已知信息足够,可跳出循环,如果不够,则将关键词传入 「search」组件进行循环搜索

「search」和「search1」组件配置相同,如下

「消息」和「消息_2」节点用于向用户展示当前进展的搜索结果

「文本处理」节点用于拼接搜索结果,并且被循环节点引用,设为输出,从而提供给后续总结大模型

循环节点结束之后,则是让 大模型根据搜索的结果进行回答的阶段

「消息_4」节点用于分割输出,方便展示
「总结大模型」节点用于让 LLM 根据已知信息进行回答,其提示词如下
# 任务- 优先参考「联网参考资料」中的信息进行回复。- 联网搜索信息可能存在错误,需要适当引用,不可全信。- 回复请使用清晰、结构化(序号/分段等)的语言,确保用户轻松理解和使用。# 任务执行遵循任务要求来回答「用户问题」,给出有帮助的回答或详细的研究报告。要尽可能详细且深度,但务必确保正确性# 历史对话{{chat_history}}# 联网参考资料{{reference}}# 当前环境信息{{meta_info}}# 用户问题:{{question}}# 你的回答

「结束」节点输出总结大模型输出即可

到此,一个基础版的效果还行的 DeepResearch 就制作好了。
可改进的方向
-
速度方面,因为搜索阶段和整个流程需要非常多的大模型介入,并且使用的是 DeepSeek R1,因此不可避免的时间较长。
-
而速度方面,可以优先考虑的就是直接替换模型,因为 DeepSeek 的 R1 模型基于 V3 进行 RL 训练而来,推理能力大大提升,但在预训练知识上其实相差不大。因此在搜索关键词生成和已知信息是否完全的判断这两方面,可以考虑切换为 V3 执行。
-
其次,目前的搜索使用的是 百度 AI 搜索插件,为了速度可以直接切换为百度搜索或其他搜索插件,如博查 AI 搜索
-
-
质量方面,主要考虑搜索关键词的优化和搜索信息源的优化。
-
搜索关键词的生成目前提示词比较直接,因此非常依赖 R1 本身的发挥。但实际上这里的提示词可以大大优化(优化≠变冗长),并且如果是一些有能力的小伙伴,其实这里的模型是可以考虑训练的,(想起一个很早的项目,清华NLP实验室的 WebCPM:https://github.com/thunlp/WebCPM)
-
搜索信息源也是直接影响质量的一部分。如果搜索源搜索质量较差,可能反而会影响模型结果,因为模型可能会被那较差的结果所误导。而对于一些垂类的领域知识,通用型的搜索源可能不一定能覆盖到,需要额外的信息补充。
-
评论
