最近在折腾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 Agent
3.1 环境准备与模型选型
我这次实现用的Python 3.10和OpenAI的接口,模型用的是gpt-4o-mini,主要考虑是便宜,方便反复做实验。你们如果用其他国产模型或者开源模型也可以,关键是模型得具备指令跟随能力和多轮对话能力,我不建议用太弱的小模型,因为Self-Ask对格式遵循性的要求比较高。
先安装依赖:
pip install openai==1.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, model="gpt-4o-mini", enable_search=True): 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( model=self.model, messages=messages, temperature=0.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(r"Follow 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 += f"Intermediate 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(reverse=True, key=lambda 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到自我评估的Agent
5.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代码,准确率立刻上了一个台阶。很多时候,我们不妨先怀疑工具,再怀疑提示词。