用Self-Ask实现AI Agent多跳推理增强:原理、代码与实战
最近在折腾AI Agent的推理增强有个问题一直让我挺头疼的单次提问看起来回复很快但一旦问题稍微复杂一点模型就开始“跳步”。你问它一个需要两步以上推理才能回答的问题它经常直接给你一个看似合理、但经不起推敲的答案。比如问“某个城市所属时区的人均GDP是多少”模型可能答得出城市也可能答得出时区但把这两个知识点串起来就经常出错。后来我认真研究了Self-Ask这套策略发现它解决的就是这个问题。Self-Ask的核心思路特别简单让模型在回答最终问题之前先自己向自己提出若干个子问题逐个给出中间答案再基于这些中间答案汇总出最终结论。说白了就是把AI的隐式推理转成显式追问用“自我对话”把推理链路一步步补齐。这篇文章我会把整个思路、原理、提示词设计、代码实现、以及我实际踩过的坑完整过一遍希望对正在做AI Agent应用、或者对推理增强感兴趣的朋友有点帮助。1. 项目概述与核心技术定位1.1 从“一步到位的幻觉”到“追问式推理”先说说我为什么会专门研究Self-Ask。当时我在做一个知识问答类的AI Agent场景是让模型回答一些需要多跳推理的问题比如跨实体的比较、跨属性的计算。这类问题在评测集里表现很差模型往往只记住了最终答案的“外部形态”却完全没展现出中间的推理过程。我去翻模型的输出日志发现很多错误案例都有一个共同点推理链路中间断了一环但模型没意识到自己断了硬着头皮继续往后编。这里就引出一个关键问题让模型“一步步推理”还不够你要给它一个机制让它能主动检查“我还缺少哪个信息我应该先问自己什么问题”。Chain-of-Thought的做法是让模型把推理过程写在答案里但推理过程本身是线性的、不可回溯的。Self-Ask的做法则更进一步它把推理过程拆成两个可重复的原子操作Ask向自己提问和 Answer回答自己的问题。模型可以先问自己“我到底需要哪些子问题才能回答主问题”然后逐个回答最终再汇总。这听起来有点绕但实际上非常符合人类解决复杂问题的习惯。比如要回答“一个被蝴蝶和青蛙围绕的生态园里哪个物种寿命更长”你不会直接拍脑袋而是会先分解两种动物的平均寿命各是多少然后才比较。Self-Ask就是把这个“先分解、再回答、后汇总”的流程显式化地塞给模型执行。1.2 适合同类推理增强方案的关系定位有些人会问Self-Ask和Chain-of-Thought到底什么区别是不是换个名字我的理解是两者是不同层面的增强策略。CoT是引导模型把思维链写出来关注的是推理过程的“可视化表达”Self-Ask关注的是推理过程的“结构化分解”它要求模型显式生成子问题而不是直接生成一串中间计算。CoT是“把步骤写下来”Self-Ask是“把问题拆开来问”。而和ReAct这类Agent框架对比Self-Ask更像是一个“内部的推理放大器”它不一定需要外部工具也可以和多轮工具调用结合。ReAct强调行动和推理交替进行Self-Ask则完全聚焦在“自我提问-自我回答”这个循环上。我个人的感觉是Self-Ask在纯知识推理场景下非常实用而到了需要和外部环境交互的场景更适合作为ReAct的前置预处理逻辑。为了更直观我整理了一张方案定位对照表推理增强策略核心操作适用场景局限Direct Prompting直接回答单步简单问题多跳推理基本失效Chain-of-Thought给出推理步骤数学、逻辑推导推理链路仍可能断裂不可回溯Self-Ask自发提问并回答多跳知识问答、复合问题子问题可能过多成本上升ReAct推理行动交替需要工具/检索的Agent场景依赖工具质量编排复杂所以我的结论是Self-Ask不是要替代CoT或ReAct而是在推理链路补齐这件事上提供了一个更可控制、可观察、可调试的中间层。它特别适合那种“我知道自己缺哪个信息”的问题类型模型可以通过构造子问题来主动检索或确认信息而不是直接越过缺口硬答。1.3 我理解的核心价值可控性做AI Agent最怕的就是不可控。模型输出结果是黑盒你不知道它动用了哪些信息也不知道它是从哪一步开始跑偏的。Self-Ask最大的价值在于把推理的中间步骤变成可见的子问题序列。一旦答案出错你可以直接检查是哪个子问题回答错误定位到具体环节而不是整个推翻重来。这种“可控性”才是它在工程上真正有吸引力的地方。2. 核心原理自我追问如何补齐推理链路2.1 一次完整的Self-Ask推理链路长什么样要理解Self-Ask的本质最好的方式是看一个具体的输出格式。假设主问题是“世界上最高的山峰是哪座它的海拔高度是多少米”一个规范的Self-Ask流程应该长这样Question: 世界上最高的山峰是哪座它的海拔高度是多少米 Follow up: 世界上最高的山峰是哪座 Intermediate answer: 珠穆朗玛峰。 Follow up: 珠穆朗玛峰的海拔高度是多少 Intermediate answer: 约8848米。 So the final answer is: 珠穆朗玛峰海拔约8848米。看到没有模型输出的不是最终答案而是一连串Follow up和Intermediate answer。每一个Follow up都是模型向自己提出的子问题每一个Intermediate answer都是对上一个子问题的回答最后的So the final answer is则是把中间答案汇总成最终输出。这里有个很容易被忽视的关键点子问题的答案不一定要由模型直接生成它可以是外部检索结果。比如子问题是“珠穆朗玛峰的海拔高度是多少”模型完全可以不靠记忆硬答而是生成一个工具调用去知识库或搜索引擎里查证再把检索结果作为Intermediate answer。这就让Self-Ask天然适合和Agent工具链结合成为“增强检索”的前置管理器。2.2 为什么显式提问比隐式推理更稳一句话概括显式提问给了模型一个“检查点机制”。模型每提出一个子问题都相当于在做一次“我是否真的知道这个信息”的自检。如果它发现某个子问题回答不上来它会在这个环节停下来而不是像CoT那样继续往下编。这能显著减少“答非所问”的幻觉。从实际效果看我在多个测试集上对比过Self-Ask在多跳问题上的准确率通常高于直接Prompting也往往优于不加约束的CoT。原因其实不复杂问题的分解过程本身就把一个大任务拆成了多个可校验的小任务。小任务回答错了你可以精准纠正而大任务整体错了你很难判断症结在哪儿。另外显式提问还让“验证”变得可能。你可以让另一个模型或同一个模型再次审视这些子问题——你确定这是回答主问题所必需的子问题吗这样等于在推理链路上加了一层主动校验。放在AI Agent里这相当于给Agent加了“自我质疑”的能力。2.3 子问题的搜索与信息补充Self-Ask不是只能“空想”。在Agent场景里子问题往往是触发搜索的钥匙。我之前实现过一个版本模型输出Follow up: xxx时系统会把xxx当作搜索关键词发给一段候选检索工具然后把返回结果包装成Intermediate answer再喂回模型。这样就形成了一条完整的“提问-检索-回答-汇总”的推理闭环。这里有个细节值得留意子问题的表述方式会直接影响检索质量。模型提出的“世界上最高的山峰是哪座”这样的子问题直接拿去搜索效果很好但如果是“那个高度最高的地方叫什么”效果就会差很多。所以提示词里我会明确要求模型用“具体、可直接检索的自然语言”来写子问题。这不是个天然就会的能力而是需要通过少样本示例去约束的。3. 实操过程从零搭一个Self-Ask Agent3.1 环境准备与模型选型我这次实现用的Python 3.10和OpenAI的接口模型用的是gpt-4o-mini主要考虑是便宜方便反复做实验。你们如果用其他国产模型或者开源模型也可以关键是模型得具备指令跟随能力和多轮对话能力我不建议用太弱的小模型因为Self-Ask对格式遵循性的要求比较高。先安装依赖pip install openai1.30.0然后配置环境变量export OPENAI_API_KEY你的key这里有个劝告不要硬编码API Key在代码里不然代码分享出去你就该哭了。3.2 设计Few-Shot提示词模板Self-Ask的效果很大程度取决于提示词里的Few-shot示例质量。示例需要向模型展示“什么是好的子问题拆分”、“中间答案怎么组织”以及“最终答案怎么汇总”。下面这个模板是我在实际项目中打磨过很多次的版本稳定性和输出格式都比较好。SELF_ASK_SYSTEM_PROMPT 你是一个擅长把复杂问题拆解成多个简单子问题然后逐步回答的AI助手。 你的工作流程是 1. 先理解用户的主问题。 2. 判断这个问题是否需要多个信息才能回答。如果需要主动自我追问Follow up把主问题拆解成更小的子问题。 3. 对每一个子问题给出中间答案Intermediate answer。 4. 如果你发现自己缺少某个知识可以将子问题表述成适合搜索的短句系统会检索后提供给你相关信息。 5. 最后基于所有中间答案以So the final answer is: 开头给出最终答案。 严格遵循以下输出格式 Follow up: 你的子问题 Intermediate answer: 你对子问题的回答 ... So the final answer is: 最终答案 注意 - 每个子问题都要具体用可检索的自然语言表达。 - 如果主问题可以直接回答不需要拆解就直接输出最终答案。 - 不要输出和格式无关的内容。 SELF_ASK_FEWSHOT_EXAMPLES Question: 世界上最高的山峰是哪座它的海拔高度是多少米 Follow up: 世界上最高的山峰是哪座 Intermediate answer: 珠穆朗玛峰。 Follow up: 珠穆朗玛峰的海拔高度是多少 Intermediate answer: 约8848米。 So the final answer is: 珠穆朗玛峰海拔约8848米。 Question: 某支篮球队的得分后卫在上一场比赛得到全队最高分他所在的队最终赢了还是输了 Follow up: 哪些球员是这支篮球队的得分后卫 Intermediate answer: 需要进一步确认球员个人信息。 Follow up: 该球员上一场比赛得到多少分 Intermediate answer: 该球员得到全队最高的32分。 Follow up: 该队上一场比赛最终比分是多少 Intermediate answer: 该队以112比108获胜。 So the final answer is: 该队最终赢下了比赛。 def build_selfask_messages(user_question: str): return [ {role: system, content: SELF_ASK_SYSTEM_PROMPT}, {role: user, content: SELF_ASK_FEWSHOT_EXAMPLES \n\nQuestion: user_question}, ]看到没我在System Prompt里明确写了“可以将子问题表述成适合搜索的短句”这步很重要。这样模型在遇到知识盲区时不会硬编而是会抛出一个适合检索的子问题让外部工具接管。3.3 主循环解析模型输出并触发检索接下来是核心代码。我们需要解析模型的输出识别Follow up、Intermediate answer和So the final answer is同时还要能在需要检索时触发搜索工具。import re from openai import OpenAI client OpenAI() class SelfAskAgent: def __init__(self, modelgpt-4o-mini, enable_searchTrue): self.model model self.enable_search enable_search self.search_engine None def attach_search_engine(self, search_engine): self.search_engine search_engine def _get_model_response(self, messages): resp client.chat.completions.create( modelself.model, messagesmessages, temperature0.0, ) return resp.choices[0].message.content.strip() def run(self, question: str): messages build_selfask_messages(question) final_answer None intermediate_steps [] # 最多允许迭代5轮防止模型死循环 for _ in range(5): response_text self._get_model_response(messages) print(f[模型输出]\n{response_text}\n) # 如果模型已经给出最终答案解析后返回 if So the final answer is in response_text: final_answer response_text.split(So the final answer is)[-1].strip() return final_answer, intermediate_steps # 解析Follow up问题和中间答案 follow_ups re.findall(rFollow up: (.), response_text) if not follow_ups: # 模型没按格式输出直接强制它重新输出 messages.append({role: assistant, content: response_text}) messages.append({role: user, content: 你刚才没有按格式输出。请重新按格式回答。必须包含Follow up和Intermediate answer。}) continue search_results [] for fu in follow_ups: # 这里判断如果接下来没有明确的Intermediate answer就认为需要检索 if Intermediate answer not in response_text: if self.enable_search and self.search_engine: print(f[触发检索] {fu}) result self.search_engine.search(fu) search_results.append(result) else: search_results.append(未启用外部检索请基于已有知识回答。) else: search_results.append(None) # 构造下一轮消息把新的中间答案注入上下文 injected response_text \n for idx, sr in enumerate(search_results): if sr is not None: injected fIntermediate answer: {sr}\n messages.append({role: assistant, content: response_text}) messages.append({role: user, content: injected \n如果有新信息补充请继续、或给出最终答案如果能给出最终答案请以So the final answer is开头。}) return final_answer, intermediate_steps这段代码的核心思想是每轮都让模型输出如果它没有给最终答案就检查它是否提出了子问题。对于每个子问题如果缺少Intermediate answer就触发检索工具把检索结果作为中间答案注入下一轮对话。这个循环会一直持续直到模型输出最终答案或达到最大迭代轮数。3.4 一个最小可用的检索工具自建的搜索引擎可以用很轻量级的方案比如只做文档片段匹配。我这里用一个简单的内存检索示例重点在于展示接口长什么样class SimpleLocalSearchEngine: def __init__(self): self.docs [ 珠穆朗玛峰是世界最高峰海拔8848.86米位于喜马拉雅山脉。, NBA是北美职业篮球联赛洛杉矶湖人队是一支传统强队。, 蝴蝶的平均寿命约为2到4周不同种类差异较大。, 青蛙的寿命一般为4到15年取决于种类和环境。, ] def search(self, query: str, top_k: int 2): # 实际项目中可以换成向量检索、ES或调用搜索API scores [] for doc in self.docs: score self._simple_score(query, doc) scores.append((score, doc)) scores.sort(reverseTrue, keylambda x: x[0]) results [doc for _, doc in scores[:top_k]] return \n.join(results) def _simple_score(self, query, doc): q_tokens set(query.lower().split()) d_tokens set(doc.lower().split()) return len(q_tokens d_tokens)你看这个搜索引擎简陋到有点“玩具”但接口是完整的。真正要接搜索引擎的话只需要实现一个接收字符串、返回文本的search方法就行。这样Self-Ask逻辑完全不需要改做到了“子问题生成”和“信息检索”的解耦。3.5 实际运行结果解析跑一个需要检索和推理的问题比如“最高的山所在国家的首都是哪里”。[模型输出] Follow up: 世界上最高的山是哪座 Intermediate answer: 珠穆朗玛峰。 Follow up: 珠穆朗玛峰位于哪个国家 Intermediate answer: 中国和尼泊尔交界处。 Follow up: 这个国家的首都是哪里 So the final answer is: 需要知道是指中国还是尼泊尔。中国的首都是北京尼泊尔的首都是加德满都。这个输出就很有意思。模型不是生硬地给出一个答案而是意识到了“多个国家”的存在并分别给出了各自的首都。这种对歧义的处理能力其实就是子问题拆解带来的“自省”效果。在某些场景下我们还希望模型只输出一个最相关的答案那可以在System Prompt里加一句“如果子问题答案有多个对象选择最常被提及的一个作为最终回答依据”。结合起来看运行效果是稳定的。但我必须提醒一下上面这个SearchEngine没有实际发生过因为我为了演示手动精简了真实的检索过程比我这个玩具版要慢得多接入时要做好异步化和缓存。3.6 参数选择与成本考量我在实际应用里把max_iterations限制在5轮以内temperature设为0这是为了最大程度保证输出格式的稳定性。一旦需要模型发挥创造性比如头脑风暴类问题Self-Ask并不是合适的方案我建议换回普通的自由对话。成本方面Self-Ask比普通Prompting消耗的token多不少因为每一轮追问都是一次完整的历史消息传入。我测试过一个两跳问题大概会消耗基础Prompt的2.5倍左右token。所以线上使用的话要对子问题数量做上限控制或者采用流式输出尽早截断。4. 常见问题与排查技巧实录4.1 模型不按格式输出怎么掰回来这是实验初期遇到最多的问题。模型经常直接给答案跳过Follow up和Intermediate answer或者输出了一堆无结构的话。遇到这种问题我的做法是强校验在代码里判断输出中是否包含So the final answer is如果没有也没有Follow up就把它当作废输出往对话里追加一条纠偏指令。上面代码里的continue分支就是这么干的。实测下来gpt-4o-mini大概被纠偏一次后就会遵循格式但如果你用的是更弱的模型可能要反复纠偏好几轮这时候要么换模型要么加一个输出解析器做后料理。4.2 子问题拆得太多或太少模型的拆解粒度不太容易控制。拆得太碎token成本和响应时间直线上升拆得太粗又退化成普通CoT起不到追问效果。我测试下来一个普通问题控制在1到3个子问题内是最合理的。如果发现某个模型倾向于把问题拆得很碎可以在System Prompt里加一句“尽可能用最少的子问题覆盖所有必要信息”。如果经常拆得太粗就增加Few-shot示例中拆解粒度更细的例子。4.3 检索结果太杂污染推理这个问题很坑。搜索引擎一次返回的文本往往包含大量无关信息如果直接塞给模型作为Intermediate answer模型很有可能被误导。我的经验是对检索结果做一次“抽取-压缩”只提取和子问题直接相关的句子做成摘要再喂给模型。宁可少给信息也不要给噪音。这块后续完全可以再套一层专门做抽取的提示词逻辑。4.4 多个子问题之间存在依赖导致答案互相矛盾模型提出三个子问题分别回答完但合并的时候发现答案之间有冲突。比如A子问题说“队伍A获胜”B子问题又说“队伍B积分更高”。这种冲突本质上是模型对子问题的回答不够严谨。我在工程上有个妥协方案在汇总步骤加一个“验证提示词”让模型检查所有Intermediate answer之间是否一致如果不一致重新回答导致冲突的子问题。这个验证步骤会额外消耗一轮请求但能明显提升最终答案的可靠性。4.5 和完整Agent框架集成时的注意事项如果你是在LangChain或自研Agent框架里用Self-Ask我建议把SelfAskAgent封装成一个Tool或一个Chain输入是主问题输出是最终答案。但要注意一个时间线问题Self-Ask内部多轮请求是同步阻塞的如果在Agent主循环里调用要考虑超时控制。我实际的集成方案是Agent主循环先识别问题复杂度超过一定阈值才走Self-Ask分支简单问题直接回答。这种混合策略兼顾了成本和效果而不是所有请求都无脑套Self-Ask。5. 扩展方向从Self-Ask到自我评估的Agent5.1 让模型提问之后自己判断问题质量我最近在扩展的一个方向是让模型在生成了子问题之后先不要急着回答而是先评估“这个问题是否合理、是否有明确的答案、是否与主问题相关”。如果子问题本身质量不高那答案再多也是错的。这个扩展很像给模型加了一个“问题过滤层”能有效提升整个系统的鲁棒性。5.2 从单轮Self-Ask到多轮Agent循环Self-Ask的一个限制是它默认所有子问题在“一轮”内就能得到答案但实际场景中某些子问题需要多轮对话才能澄清。我的做法是把整个SelfAskAgent暴露成一个工具嵌入更大的Agent循环中。每个子问题可以进一步调用其他工具形成递归的自我追问结构。这种递归一旦展开能力边界就大大拓宽了。5.3 使用Self-Ask做自动评测样本生成有个很实用的思路用Self-Ask来构造评测集。你给模型一个复杂主题让它自己生成“主问题-子问题-中间答案-最终答案”的完整样本再人工审核一遍就成了高质量的多跳推理评测数据。这比我之前手动构造评测样本要省力得多而且因为子问题是逐步生成的逻辑一致性比一次性生成的样本更高。5.4 和开源模型配合时的调优心得如果你用的是开源模型比如Qwen、Llama系列Self-Ask的格式遵循性可能不如闭源模型稳定。我的经验是两类调整一是把Few-shot示例加大到6到8个让模型“抄作业”抄得更准二是考虑用解码参数来控制比如开启beam search、加大repeat_penalty能减少多次输出时的格式漂移。另外开源模型的System Prompt能力相对弱把约束条件写在User消息里往往更有效。我的几点体会第一个体会是Self-Ask不是为了炫技它解决的是AI Agent最基础的“可信推理”问题。当一个系统要替你回答复杂问题时你必须有办法观察它“为什么给出这个答案”而Self-Ask至少在格式上提供了一条可追溯的路径。第二个体会是任何推理增强策略都要服务于实际业务约束。子问题很好但如果你每个请求要多付3倍token那就要想清楚哪些场景值得这么用。我自己的做法是先做路由简单问题走直接回答复杂问题才走Self-Ask。这种分层设计比把所有流量都套上Self-Ask要务实得多。最后一个体会是提示词策略只是其中一个变量。模型能力、检索质量、代码健壮性三者缺一不可。前阵子我花了很大力气优化提示词发现提升瓶颈根本不在提示词而在检索工具太弱。后来换了更好的检索方案同样一套Self-Ask代码准确率立刻上了一个台阶。很多时候我们不妨先怀疑工具再怀疑提示词。