🚓 分诊台与拆题术:让 RAG 学会判断和规划

0 阅读11分钟

写在前面:上篇我们搭了一个能跑的朴素 RAG,也认领了它的五个硬伤。这篇开始打补丁——针对其中两个最"伤智商"的问题:所有问题都无脑检索(1+1 也要翻书)和多跳问题搜不准(答案不在任何一段原文里,要跳好几次)。readme 给的药方很明确——前者靠"llm 来判断简单",后者靠"llm 规划能力,拆分,分步骤"。两个 demo 文件,一个教 RAG 分诊,一个教 RAG 拆题。以下所有代码均来自课堂真实文件。


一、补丁一:给 RAG 装一个分诊台

医院的分诊逻辑

去医院看病,进门先遇到分诊台——不是所有病人都直接推进手术室。护士问两句:

  • "挂个号开个药" → 普通门诊
  • "胸口疼、呼吸困难" → 急诊

分诊的价值在于把资源用在对的地方。对所有病人一律上全套检查,是浪费,也是灾难。

朴素 RAG 就是那种"全部上全套"的医院——你问"1+1 等于几",它也要去《天龙八部》里搜五段原文出来。readme 的判断:

"所有问题都走 RAG 检索?简单问题不需要检索,浪费资源(token 和 检索,增强流程)。1+1=?两个分支,一个简单的问题,一个复杂的问题。llm 来判断简单?"

"llm 来判断" —— 这四个字就是解法。

路由状态:先加一个"路线"字段

rag-query-router.mjs 比上篇的朴素版本多了一个关键字段:

const GraphState = Annotation.Root({
    question: Annotation,
    k: Annotation,
    strategy: Annotation,      // ← 新增:策略
    routeReason: Annotation,   // ← 新增:判断理由
    documents: Annotation,
    generation: Annotation,
})

strategy 存的是"走哪条路",routeReason 存的是"为什么这么走"——后者不只是为了好看,它是可观测性的基础。 出了问题你能看到模型当时是怎么想的。

路由节点:用结构化输出做判断

const RouteSchema = z.object({
    // 枚举
    strategy: z.enum(["simple", "complex"]),
    reason: z.string(),
});

这里用了结构化输出 + Zod 枚举——前面专门学过的知识,现在派上大用场了。

z.enum(["simple", "complex"]) 意味着模型只能在这两个值里选一个。这就避免了模型回答"可能是简单的吧"这种没法用代码判断的模糊输出。

const routeQuestionNode = async (state) => {
    console.log('___ROUTE-QUESTION___');
    // 结构化输出
    const router = model.withStructuredOutput(RouteSchema);
    const route = await router.invoke(`
    你是问答路由器,请判断用户问题是否需要外部检索。
    规则:
    - simple 常识问答、简短定义、无需特定小说细节即可回答。
    - complex 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
    
    用户问题: ${state.question}
    `)

    console.log(`路由策略:${route.strategy} ${route.reason}`)
    return {
        question:state.question,
        k:state.k,
        strategy: route.strategy,
        routeReason: route.reason,
    }
}

这就是"分诊护士"的全部工作。 几句 prompt 说清判断标准,剩下的交给模型。

prompt 里的两句规则设计得很精准:

策略判断标准
simple常识问答、简短定义、无需特定小说细节
complex需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据

关键词是"无需特定小说细节"和"需要原文细节或证据"——把判断标准落在"是否需要查资料"这个本质问题上,而不是落在"问题长短"这种表面特征上。

这才是好的路由 prompt:给判断依据,不给判断捷径。

两条分支:直答 vs 检索

const directAnswerNode = async (state) => {
    console.log('___DIRECT-ANSWER___');
    process.stdout.write("\n [AI 回答(流式)] \n")
    let generation = "";
    const stream = await model.stream(`你是一个中文回答助手,
    请简洁回答问题。
    问题:${state.question}
    `)
    // ... 流式输出 ...
    return {
        question:state.question,
        k:state.k,
        strategy: state.strategy,
        routeReason: state.routeReason,
        documents: [],          // ← 注意:没有文档
        generation: generation,
    }
}

注意 documents: [] ——直答节点不产生任何文档。这个细节很重要:它明确表达了"这条路没检索",而不是含糊地留空。

对比一下两条路的 prompt:

节点prompt 特点
directAnswerNode"你是一个中文回答助手,请简洁回答问题" —— 轻量、直接
generateNode(RAG 那条路)五条防幻觉要求 + 拼接的 context —— 重装备、严谨

两条路的 prompt 完全不同——这也是分诊的意义:不是省了一次检索,而是给不同复杂度的问题配不同规格的处理流程。

编排:一个条件边搞定

const decideNext = (state) => {
    return state.strategy === "simple" ? "direct_answer" : "retrieve";
}

const graph = new StateGraph(GraphState)
    .addNode("route_question", routeQuestionNode)
    .addNode("direct_answer", directAnswerNode)
    .addNode("retrieve", retrieveNode)
    .addNode("rag_generate", generateNode)
    .addEdge(START, "route_question")
    .addConditionalEdges("route_question",decideNext, {
        direct_answer: "direct_answer",
        retrieve: "retrieve",
    })
    .addEdge("retrieve", "rag_generate")
    .addEdge("direct_answer", END)
    .addEdge("rag_generate", END)
    .compile()

流程图长这样:

                    ┌→ direct_answer → END      (简单问题,直接答)
START → route_question
                    └→ retrieve → rag_generate → END   (复杂问题,检索再答)

上篇那条"一条道走到黑"的直线,现在有了岔路口。

条件边 addConditionalEdges 前面学过——decideNext 返回 "direct_answer" 还是 "retrieve",决定了走哪条。

测试它:一个"不该检索"的问题

const question = 'js写一个add函数';
const k = 5;

看到这个测试问题,我笑了——这是个用来问《天龙八部》助手的测试用例。

但它设计得太聪明了:js写一个add函数 跟《天龙八部》毫无关系,也完全不需要查小说。

  • 朴素 RAG:硬去书里搜 5 段最相似的文字(大概是"武功招式"之类的)→ 然后基于这些莫名其妙的内容回答 → 大概率胡说
  • 现在的路由版:判断为 simple → 走 direct_answer → 不检索,直接正常回答

这个测试用例的精妙之处在于:它验证的不是"能不能答对",而是"能不能不检索"。

一条负向测试——好的测试用例常常是反着设计的。


二、补丁二:给 RAG 装上拆题能力

分诊解决了"要不要检索",接下来解决"检索什么"。

多跳问题长什么样

readme 给的那道题,我上篇引用过,这里再挖深一点:

"天龙八部中 四大恶人 排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"

这道题有两个问句,但实际推理链条更长:

问句 1:四大恶人排行第二的是谁?
    ↓ 答案:叶二娘("无恶不作")
问句 2 的第一环:叶二娘的儿子是谁?
    ↓ 答案:虚竹
问句 2 的第二环:虚竹的生父是谁?
    ↓ 答案:玄慈
问句 2 的第三环:玄慈在武林中的公开身份是什么?
    ↓ 答案:少林寺方丈

要回答它,得跳四步。 而每一步的答案,分散在书中不同的章节里。

文件开头的注释也点出了另一个典型例子:

// 段誉遇到的第一个神仙姐姐画像,是谁的弟子?
// 直接把query 向量化匹配不够准确
// 支持子问题拆分的工作节点

"直接把 query 向量化匹配不够准确" —— 这句话就是病因诊断。

为什么不准?因为整句话的向量是"混合语义":

"四大恶人排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"
                            ↓ embedding
        一个混合了【四大恶人】【儿子】【生父】【公开身份】的向量
                            ↓ 去数据库搜
        找到的片段:可能在讲"四大恶人",也可能在讲"生父"
                    但很难有一段话同时覆盖这条链

一个向量,装不下一条推理链。 所以解法是——先拆,再分头检索。

拆解节点:让 LLM 当"出题老师"

const DecomposeSchema = z.object({
    sub_question: z.array(z.string()).min(1).max(8),
    reason: z.string(),
})

同样用结构化输出约束——sub_question 必须是 1 到 8 条的字符串数组。.min(1).max(8) 是 Zod 的数组长度约束。

const decomposeQuestionNode = async (state) => {
    console.log('___DECOMPOSE-QUESTION___');
    // 结构化输出
    const decomposer = model.withStructuredOutput(DecomposeSchema);
    const out = await decomposer.invoke(`
    你是《天龙八部》多跳问答的【子问题拆解器】。
    用户原始问题:
    ${state.question}
    
    任务:将问题拆成**有序**子问题列表 sub_questions,用于**依次向量检索**。要求:
    1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答也可输出一条。
    2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;可写全人物名与事件名。
    3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
    4. **不要**把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
    5. 输出 1~8 条即可。

    请输出 sub_questions 与简短 reason。
    `)

这五条要求,每一条都在防一个具体的坑。 值得逐条分析:

要求防的坑
1. 链式推理必须拆成多条防止模型偷懒,整句照搬
2. 禁止指代,要写全人物名最精彩的一条
3. 顺序符合推理链防止检索顺序颠倒导致后续查不到
4. 不要原样复制 / 不要拆太碎两头防:防不拆,也防拆烂
5. 数量限定 1-8 条防爆炸

重点看第 2 条——为什么禁止指代?

假设模型拆出这样的子问题:

❌ 他的儿子是谁?                      ← "他"指谁?检索时不知道
❌ 此人的公开身份是什么?               ← "此人"又是谁?

因为检索是"独立"的——第二个子问题去数据库搜的时候,它不知道第一个子问题查到了"叶二娘"。指代词在脱离上下文后完全失效,向量化出来就是一团模糊语义。

正确的拆法是:

✅ 叶二娘的儿子是谁?
✅ 虚竹的生父是谁?
✅ 玄慈在武林中的公开身份是什么?

注意第三句——它用了"玄慈"这个只有走完前两步才知道的名字。 这说明模型在拆解时已经推演过整条链,把中间答案都补全了。

这就是 prompt 第 3 条"顺序必须符合推理链:先搞清前置实体/事实,再查后续结论"的深意——它要求模型先自己走一遍推理,再倒着把每一步写成可检索的问句。

一个"拆解器",其实干了两件事:规划 + 预演。

拿到子问题后的处理

// 去除空格,排除不需要的question
const subQuestions = out.sub_question.map((s) => s.trim()).filter(Boolean);
if (subQuestions.length === 0) {
    throw new Error("decompose_question: sub_questions 为空");
}

console.log(`拆解${subQuestions.length}条子问题(${out.reason})`);
subQuestions.forEach((q, i) => {
    console.log(`[${i+1}] ${q}`);
});
return {
    subQuestions,
    nextSubIdx: 0,               // 从第 0 条开始
    currentQuery: subQuestions[0],
}

三层防御:

  1. .trim() 去空格
  2. .filter(Boolean) 过滤空字符串(Boolean("") 是 false)
  3. 空数组就抛异常——宁可报错,也不要带着空问题继续跑

nextSubIdx: 0 是"游标"——标记下一条要检索哪个子问题。 这是整个多跳流程的驱动器。

状态设计:12 个字段的巧思

多跳版本的状态膨胀到了 12 个字段:

const GraphState = Annotation.Root({
    question: Annotation,
    k: Annotation,
    strategy: Annotation,
    routeReason: Annotation,
    subQuestions: Annotation,      // 拆解出的子问题列表
    nextSubIdx: Annotation,        // question[i]  跳出循环
    currentQuery: Annotation,      // 当前子问题
    retrievalCount: Annotation,    // 已检索轮次
    maxRetrievals: Annotation,     // 最大轮次上限
    plannedNext: Annotation,       // 下一步决策
    documents: Annotation,
    generation: Annotation,
})

三个字段是循环控制三件套:

字段作用
nextSubIdx走到第几条了(游标)
retrievalCount已经查了几轮(计数)
maxRetrievals最多查几轮(上限)

注释写得很明白:

nextSubIdx: Annotation, // question[i]  跳出循环

question[i] 这个写法暗示了它的用法——像遍历数组一样,一条条处理子问题。 而"跳出循环"说明它兼作终止条件。

maxRetrievals 初始值 8:

maxRetrievals: state.maxRetrievals ?? 8,

?? 8 是"没设置就用 8"——给循环上了一道安全阀。

检索节点:一次查一条,累积去重

const retrieveNode = async (state) => {
    const subs = state.subQuestions ?? [];
    const idx = state.nextSubIdx ?? 0;
    const q = subs[idx]?.trim(); // 当前这一轮问题

    if (!q) {
        throw new Error(`retrieve: 子问题下标${idx} 无有效文本
            (共${subs.length}条)
        `);
    }

    const round = state.retrievalCount + 1;
    console.log(`----第${round}轮,子问题:${idx+1}/${subs.length}----`)
    console.log(`---查询:${q}---`);
    const newDocs = await retrieveRelevantContent(q, state.k);
    // 多轮retrieve 有可能重复,会浪费资源
    // 重复可能让llm 我们在强调,错觉
    const merged = mergeUnique(state.documents ?? [], newDocs);
    // ...
    return {
        documents: merged,
        retrievalCount: round,
        nextSubIdx: idx + 1,
        currentQuery: q
    }
}

这个节点每次只处理一条子问题——取出 subs[nextSubIdx],检索,然后把游标 +1。

代码注释点出了去重的必要性:

// 多轮retrieve 有可能重复,会浪费资源
// 重复可能让llm 我们在强调,错觉

重复文档有两个害处:

  1. 浪费资源——同样的内容占两份 token
  2. 制造"错觉"——如果同一段文字出现三次,模型可能以为"这个信息被多次强调了,应该很重要",从而高估它的权重

第二条很微妙——冗余会被模型误读为强调。 这是很多人想不到的坑。

去重函数:一个经典的取舍

const mergeUnique = (existingDocs, newDocs) => {
    // map key 可以是对象
    // has get
    const map = new Map();// es6新增的HashMap 数据结构 key:value
    for (const d of [...existingDocs, ...newDocs]) {
        const key = String(d.id);
        const prev = map.get(key);
        if(!prev || Number(d.score) > Number(prev.score)){
            map.set(key, d);
        }
    }
    return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score));
}

十几行代码,完成三件事:

步骤做法
去重用 Map 按 id 做 key,同一个 id 只保留一条
择优保留分数更高的那条
排序按分数降序排列

"保留高分"这个决策值得细想。

同一段文字可能被多个子问题命中——比如"叶二娘"那段,既被"叶二娘的儿子是谁"命中,也被"虚竹的生父"命中。两次的相似度分数可能不同:

第一次命中:score = 0.72
第二次命中:score = 0.85    ← 这次更相关

保留哪个?这段代码选择留高分(Number(d.score) > Number(prev.score))。

为什么合理?因为分数代表"这段话对这个具体问题的相关度"——0.85 那次说明这个子问题跟它更贴合,用高分数更真实地反映它的价值。

Number(d.score) 的强制转换是必要的——分数有时是字符串(尤其经过 JSON 序列化后),不转成数字比较会出错。

注释还提了一句 ES6 知识:

// es6新增的HashMap 数据结构 key:value
// map key 可以是对象
// has get

Map 和普通对象的区别——对象(Object)的 key 只能是字符串/符号,Map 的 key 可以是任何类型(包括对象)。这里虽然用的是字符串 key,但注释把这个特性点出来了。

Array.from(map.values()) 把 Map 的迭代器转成数组——因为 Map 不能直接 .sort()。

决策节点:该继续查,还是该回答了?

检索完一条,接下来要判断:够了吗?还要不要继续?

const NextStepSchema = z.object({
    nextAction: z.string(["retrieve" , "generate"]),
    reason: z.string(),
})

又一次结构化输出——nextAction 只能是 "retrieve" 或 "generate"。

const planNextStepNode = async (state) => {
    console.log('___PLAN-NEXT-STEP___');
    const subs = state.subQuestions ?? [];
    const nextIdx = state.nextSubIdx ?? 0;
    const remaining = subs.length - nextIdx;
    
    const subList = subs.map((s, i) => `[${i+1}] ${s}
    ${i < nextIdx ? "已检索" : i === nextIdx ? "(下一轮将检索,若选择继续)" : "未检索"}`).join("\n");

这段代码在给模型画一张进度表:

[1] 四大恶人排行第二的是谁?         已检索
[2] 叶二娘的儿子是谁?               (下一轮将检索,若选择继续)
[3] 虚竹的生父是谁?                 未检索
[4] 玄慈在武林中的公开身份是什么?     未检索

让模型清楚知道"走到哪了、还剩什么"——这是让它做出正确判断的前提。

然后把已召回的文档也喂给它:

const docStr = state.documents.length === 0
    ? "(尚无检索结果)"
    : state.documents
        .slice(0, 6)               // ← 只取前 6 条
        .map((d, i) => `
            [${i+1}] score=${Number(d.score).toFixed(4)} 第${d.chapter_num}章
            ${d.content.slice(0, 200)}     // ← 每条只取前 200 字
        `).join("\n\n");

两个截断:最多 6 条、每条 200 字。

这是上下文管理的经典手法——判断"够不够"不需要看全文,看摘要和分数就够了。全塞进去,反而浪费 token 还干扰判断。

prompt 部分写得很有意思,有一句特别关键:

const prompt = `你是多跳 RAG 规则器。检索查询已由前置步骤拆解为**有序子问题**,
若需要继续检索,下一轮将自动使用[下一条子问题] 做向量检索,你**不要**自拟新的检索句
...

"你不要自拟新的检索句" —— 这条约束很聪明。

因为模型看到检索结果后,可能冒出"我觉得应该查查 XX"的想法,自己编一个新的搜索词。但这个流程的设计是"严格按拆解好的顺序走",让它自由发挥就破坏了规划的严谨性。

这叫"约束模型的自由度"——在需要严格流程的地方,明确告诉它"别乱来"。

然后是判断规则:

请判断下一步:
1. 已有足够依据回答用户原始问题 -> nextAction=generate
2. 仍缺关键事实、且仍存在未检索的子问题、且未超过轮数上限 -> nextAction=retrieve
硬性规则:
- 若剩余未检索子问题条数为0,必须 nextAction=generate
- 若已检索轮数已到达或超过最大检索轮数,必须 nextAction=generate。

注意"硬性规则"这四个字——prompt 里说了,但代码里还要再兜一遍:

let finalNext = nextAction;
if(state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if(remaining <= 0) finalNext = "generate";
console.log(`[决策] plannedNext=${finalNext}
    (模型建议=${nextAction})(${reason})`)
return {
    plannedNext: finalNext,
}

这是"不信任模型"的正确姿势。

模型可能会忽略 prompt 里的"硬性规则"(LLM 对负向约束的遵守率并不完美)。所以代码层面再做一次强制校验——无论模型说什么,超轮数或没剩余子问题,一律转 generate。

日志还刻意区分了两个值:

[决策] plannedNext=generate (模型建议=retrieve)(...)
        ↑ 最终生效的      ↑ 模型原本想要的

把"模型建议"和"最终决策"都打出来——如果两者不一致,就能知道"是模型判断错了,还是被安全阀拦下来了"。这是非常好的调试设计。

完整的图:一个带反馈回路的闭环

const graph = new StateGraph(GraphState)
    .addNode("route_question", routeQuestionNode)
    .addNode("direct_answer", directAnswerNode)
    .addNode("decompose_question", decomposeQuestionNode)
    .addNode("retrieve", retrieveNode)
    .addNode("plan_next_step", planNextStepNode)
    .addNode("generate", generateNode)
    .addEdge(START, "route_question")
    .addConditionalEdges("route_question",afterRoute, {
        direct_answer: "direct_answer",
        decompose_question: "decompose_question",
    })
    .addEdge("decompose_question", "retrieve")
    .addEdge("retrieve", "plan_next_step")
    .addConditionalEdges("plan_next_step",afterPlan, {
        retrieve: "retrieve",       // ← 回到 retrieve!
        generate: "generate",
    })
    .addEdge("direct_answer", END)
    .addEdge("generate", END)
    .compile()

看流程图:

                   ┌→ direct_answer → END
START → route_question
                   └→ decompose_question → retrieve → plan_next_step
                                                          │
                                          ┌───────────────┤
                                          ↓               ↓
                                      retrieve ────→  generate → END
                                      (循环回来)

retrieve → plan_next_step → retrieve 形成了一个环。 这就是 LangGraph 前面学的"循环"能力——条件边指回上游节点。

而跳出循环的条件有三个:

  1. 模型判断"够了"(nextAction=generate)
  2. 子问题查完了(remaining <= 0)
  3. 达到轮数上限(retrievalCount >= maxRetrievals)

一个受控的循环:能重复,但不会失控。

拆解效果

代码里的日志会打印拆解结果:

console.log(`拆解${subQuestions.length}条子问题(${out.reason})`);
subQuestions.forEach((q, i) => {
    console.log(`[${i+1}] ${q}`);
});

如果模型拆得对,你会看到类似这样的输出:

拆解4条子问题(需要多跳推理才能确定生父身份)
[1] 《天龙八部》中四大恶人排行第二的是谁?
[2] 叶二娘的儿子是谁?
[3] 虚竹的生父是谁?
[4] 玄慈在武林中的公开身份是什么?

从一句"绕晕人"的长问句,变成四句"每句都能独立检索"的短问句——这就是拆题术的价值。

测试问题:两个版本

async function main () {
    // const question = 'js写一个add函数';
    const question = `《天龙八部》中【四大恶人】排行第二的是谁?
        此人之子在身世揭晓前,其生父在武林中的公开身份是什么?`;

注意被注释掉的那行——js写一个add函数。

这是上篇路由 demo 的测试问题,在这个多跳版本里被注释掉了。 为什么?因为这个问题会走 direct_answer 分支,压根到不了拆解节点——在这个 demo 里它测不出东西来。

留下这行注释的价值在于:它记录了两个 demo 之间的关联——同一个测试用例,在不同的架构版本里,会走出完全不同的路径。


三、两个补丁,治好了哪两个硬伤

回头对照上篇的五个硬伤表:

#硬伤这篇的补丁状态
1所有问题都检索路由分流(simple/complex 二选一)✅ 已修
2没有评估机制决策节点部分涉及(判断"够了没")🟡 部分
3处理不了多跳问题问题拆解(子问题 + 顺序检索 + 去重)✅ 已修
4语义匹配不准关键词检索⬜ 待办
5不会联网兜底网络搜索⬜ 待办

两个补丁,两个新节点结构,两次结构化输出的实战应用。

值得留意的是——这两个补丁的技术手段其实是同一个:让 LLM 做判断。

  • 路由分诊:判断"简单还是复杂"
  • 下一步决策:判断"继续还是停止"

而多跳拆解是"让 LLM 做规划"——判断是"选择题",规划是"简答题"。 前面的结构化输出(z.enum)天然适合做选择题,后面的子问题拆解(z.array(z.string()).min(1).max(8))用来做简答题。

这就是上篇说的"在这个骨架上插节点"——骨架上插的每个节点,本质都是一次"让模型做一次结构化判断"。


四、从"流水线"到"有回路"

最后看一下这两个 demo 的架构变化。上篇的朴素 RAG:

START → retrieve → generate → END

直线,走完就完。

这篇的两个版本:

路由版:  START → route → (direct | retrieve→generate) → END
                       ↑ 岔路口

多跳版:  START → route → decompose → retrieve → plan ─┐
                                              ↑        │
                                              └────────┘
                                              (回路)

岔路口 + 回路。

这正是前面 LangGraph 那节课讲的核心——从"线性流水线"升级到"网状图"。 而这张网的每个分叉点、每个回路点,决策者都是 LLM 自己。

readme 的总结很到位:

"死板的检索生成流程,升级为可思考、可判断、可纠错的智能 RAG 架构。"

  • 可思考 → 拆解问题(规划)
  • 可判断 → 分诊、决定继续还是停止
  • 可纠错 → 还没做完(下一篇的评估节点)

PS:这两篇看下来,最大的感受是——RAG 的升级不是在"检索算法"上做文章,而是把决策权交给模型。以前是"所有问题都走同一条路",现在是"让模型看看这是什么问题、走到哪了、够不够了"。下一篇我们补上最关键的一块:让 RAG 知道自己"不知道",并且学会出门求助。