ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用特殊tag信号驱动大模型自主检索与反思,有效降低幻觉率

用特殊tag信号驱动大模型自主检索与反思,有效降低幻觉率

把问题丢给大模型,最怕的就是它一本正经地编答案。早前我在做一个内部问答Demo时遇到过这么一幕:用户问的是某行业近一年的数据变化,模型的训练样本根本没覆盖到,结果它毫不心虚地按旧数据推了一段结论,乍一看很顺,细看全是错误。后来我尝试的方向,不是给它加更多提示词,而是让模型带着一组特殊tags去生成,在生成过程中自己判断“继续输出”“反思”还是“检索”。

说白了,就是把大模型自主检索与反思的能力,通过tag信号机制暴露给外部流程。这套方案跑通之后,我的问答系统在幻觉率和时效性问题上的表现都有明显改善,这里把完整的原理、实现和踩坑过程记录一下,给正在搭Agent、RAG或想让模型回答更可靠的朋友参考。

1. 为什么要让模型自己决定“下一步做什么”

1.1 一次性生成的短板

传统的问答式调用,流程是“用户提问、模型组织上下文、一口气生成最终回答”。这种模式对闲聊问题没问题,但一旦遇到需要核实事实、需要外部数据、或者答案要经过多轮推导的任务,就会暴露出两个死穴。

第一个死穴是知识截止点。模型参数里存的知识永远是训练结束那一刻的快照,之后发生的事它根本没见过,但它在生成时不会主动说“我不知道”,而是会基于相近的历史内容做推断,输出看起来合理但实际错误的结果。第二个死穴是没有自我修正通道。普通生成过程一旦开始,中间出的错会被后续的“顺滑续写”掩盖,模型自己也不会停下来回头检查,错误结论往往被一路推进到最终答案。

很多人想到的解决办法是在prompt里加一句“如果不知道就检索”,但这句话只是心理安慰。原因很简单:模型没有外部检索能力,也没有一个明确的时机去停下来触发检索。它可以在“知识截止”这条信息上撒谎,你也拦不住。

这就是为什么我们需要一个可被外部流程捕获的决策信号,让模型在回答的途中主动喊停。

1.2 三种动作的本质区别

我最后把整个系统收敛成了三个动作,分别对应模型在生成过程中的三种心态。

  • 继续输出:模型认为当前信息足够,逻辑也没问题,就继续把话说下去;
  • 反思:模型发现自己刚才写的内容有矛盾、遗漏或不确定性,需要针对已有输出做一轮自我检查;
  • 检索:模型认为手上没有足够依据,需要查询外部知识库、数据库或搜索接口,拿到新资料再继续。

这三种动作的本质区别不只是“做什么”,更是“外部系统需要介入到什么程度”。继续输出时外部系统只需要放行;反思时需要把“反思内容”重新喂回模型;检索时需要调用检索器并重新组织上下文。如果把三个动作揉在一个大循环里,状态管理会非常混乱,所以必须靠明确的信号分开它们。

1.3 特殊tag把决策点暴露给流程

这里的关键设计,是在模型的输出token序列里植入几个特殊tags。我用的是一组尖括号样式的标签,单个token生成,例如<|SEARCH|>、<|REFLECT|>、<|CONTINUE|>。

模型在生成正文时,一旦它判断“这里该停一停”,就会在正文后面输出一个tag。外部解析器看到这个tag,就立刻把后续工作接管过来:截断输出、记录正文、进入对应分支。tag本身不会进入最终答案,它只是模型和外部系统之间的“暗号”。

这就像开车时在路口设置了三色信号灯。模型不负责判断路况,只负责变灯;真正的路权管理由外部系统完成。这样做的好处是,模型不需要拥有工具调用、API请求等额外能力,只需要在文本层面多输出一个标记,工程实现难度骤降。

2. 特殊tags的生成与“截断-解析”机制

2.1 为什么用文本tag而不是结构化JSON输出

一开始我也想过直接用JSON格式让模型输出{"action": "search", "query": "..."},因为看起来更规范。实际跑起来后发现两个问题。

第一个是json解析容易因为缺括号、漏逗号直接失败。模型输出应用时,被截断、被网络抖动影响是常事,一个不完整JSON会让整个循环直接卡死。第二个是JSON结构本身的“强约束”会诱导模型过度思考,导致它不先说完正文,而是把大量输出都留给“动作声明”,回答反而变得文绉绉。

文本tag的优势在于:它是一个极小颗粒的控制信号,模型在正文中间自然带出来,不需要额外的结构。而且现代解码器对单token的“停止字符串”支持得很好,你可以直接把<|SEARCH|>这类tag作为停止条件,一旦生成到这一位就停下来,不需要硬等模型把一整段JSON补完。

2.2 停止字符串与流式缓冲的配合

在实际接入时,我给生成接口传了这样一个stop列表:["<|CONTINUE|>", "<|REFLECT|>", "<|SEARCH|>", "<|END|>"]。模型生成到任一tag就会停止,外部流程拿到的内容里,正文在tag之前。

不过这里有个细节容易踩坑:不是所有后端的stop参数都支持“字符串匹配”,很多服务只支持“单token id匹配”。如果tag被词表切成多个token,stop字符串就失效了。我的解决方案是在tokenizer里把<|SEARCH|>、<|REFLECT|>、<|CONTINUE|>注册成独立token,不做进一步切分;或者退一步用多轮判断——当生成的后缀包含tag前缀时,强制终止并做文本级截断。

如果你走流式接口,情况会再复杂一点。模型可能在你还没收到完整tag时就吐出了它的前半段。你需要维护一个小的字符缓冲区,每次收到流式增量都检查“缓冲区 + 当前文本”是否匹配tag的某个前缀,匹配到之后继续等,直到完整匹配或确定性不匹配才决定是否截断。不要只判断“包含tag”这个条件,否则容易把正文里恰好出现相似词的内容也截掉。

2.3 解析器和载荷提取

解析器要做的事情,其实比我一开始想象的多得多。我在第一个版本只做了output.find("<|SEARCH|>"),发现一旦正文里出现类似字符串,即使模型没有真的想检索,也会被误判为动作信号。后来我的解析逻辑是这样的:

  • 从原始输出中找出第一个“完整独立的tagtoken”;
  • tag之前的部分当作正文内容;
  • tag之后的部分要看紧接着的内容是否有实际载荷;
  • 如果没有载荷,就把action置为对应分支,等待下一轮专门指令;如果有载荷(比如模型在<|SEARCH|>后面自己带了query),就按载荷格式去解析。

实践下来,我建议搜索动作的载荷单独收,不让模型在同一个输出里既完成动作又完成查询。原因也很朴素:模型在回答中途转入检索时,对“查询语句”的表达往往比较随意,不如专门给它一轮“请把刚才的意图转成检索词”的指令,出来的query质量更高。

2.4 会话状态与动作路由

一个完整轮次的会话状态,我概括成几个字段:history(多轮对话记录)、current_answer(模型已输出的正文)、reflection_note(最近的反思笔记)、retrieved_docs(已检索到的资料片段)、step(当前循环次数)。

每次接收到tag后,外部系统就根据action做路由:CONTINUE把正文拼回去继续生成,REFLECT进入反思子流程,SEARCH进入检索子流程。这其实就是一个状态机,状态迁移条件完全由tag驱动。状态机的好处是能挡住很多异常情况,比如连续出现两个SEARCH,系统就可以拦截掉一个,避免无意义地重复检索。

3. 完整循环:生成、反思、检索、再生成

3.1 系统提示词里的决策规则

要模型在正确的时候输出tag,必须先在系统提示词里把规则写明白。我的第一版提示词特别啰嗦,塞了很多“你应该”的抽象描述,结果模型反而畏首畏尾,频繁输出REFLECT。后来我改成了一种更接近“决策清单”的写法,并且补了few-shot示例。

你是业务助手。在回答之前,请按以下规则决定是否输出特殊标记: - 如果当前问题在你的知识范围内,直接回答,不要输出标记。 - 如果回答过程中你发现自己缺少关键信息,输出 <|SEARCH|>,下一轮我会让你补充检索词。 - 如果你发现之前的回答存在明显矛盾、错误或关键假设不成立,输出 <|REFLECT|>,下一轮我会引导你反思。 - 如果你觉得现在信息足够但还没完全写完,并且需要再多写一段,输出 <|CONTINUE|>,我会把续写权限交还给你。 注意:这些标记不会被用户看到。除了标记以外的正文,才会作为你的最终回复。

这版规则的关键是给模型明确了“触发条件”。只告诉它“随时可以反思”是不够的,还要告诉它“什么时候不要反思”,比如“当你有明确依据时不要反思”,这能大幅降低空转率。

3.2 REFLECT回路:先停下,再专门反思

当解析到<|REFLECT|>后,我不急着让模型重新生成答案,而是先把它拉进一个单独的反思小流程。具体做法是往history里追加一条用户消息,内容大致是:

你刚刚输出了 <|REFLECT|> 标记。现在请你审视自己上一次输出的正文内容,列出你怀疑的地方: 1. 哪个结论是你没有把握的?为什么? 2. 上下文里有没有自相矛盾的地方? 3. 如果要修正,你觉得应该换成什么原则或依据? 只输出你的反思笔记,不要尝试补写正式回答。

这一步会用相对低的生成温度,让模型专注于“挑错”,而不是顺着原来的话继续圆。得到反思笔记后,再追加一条“结合反思笔记修正正式回答”的用户消息,并用正常温度生成最终答案。

反思笔记一定不要直接拼到history末尾就完事。我会把它单独放到一个reflection_note字段中,并在下次生成时以“用户提供的附加信息”形式出现。否则模型容易把反思内容和正式回答混在一起,变得语无伦次。

3.3 SEARCH回路:检索意图与query提取

如果解析到<|SEARCH|>,同样不做和正式回答相关的生成,而是先让模型输出检索query。我的做法是在history后追加:

你刚刚输出了 <|SEARCH|> 标记。现在请把你想检索的问题转成一个简洁的查询语句,只输出查询语句本身,不要输出任何解释或格式。

query拿到后,我会交给检索引擎。这里既可以是向量检索,也可以是倒排索引、SQL查询、甚至外部搜索API。关键点是:检索结果回传后,不要把整个知识库dump给模型,而是只选最相关的3~5条,切成长度受控的片段。

3.4 上下文字段设计:历史、反思记录、检索资料

我吃了很多亏之后,把上下文字段整理成了下面这种清晰的结构:

对话历史: {多轮对话内容} 你的推理过程: {模型已输出的正文,以及近期的反思笔记} 外部资料: {检索返回的相关片段,每条标明来源和序号} 请结合外部资料继续回答用户的问题。如果外部资料与你的既有知识冲突,优先采用外部资料。

为什么要单独放“外部资料”段?因为模型对上下文位置的敏感度很高,如果检索片段被塞在很后面或混在历史中,它往往读不到。单独给它一个明确的“权威参考区”,生成时会更愿意参考,而不是凭记忆硬写。

3.5 收敛控制:最大轮数、token预算与终止条件

不设终止条件的话,这个循环可能永远出不来。我在代码里设了三道闸。

第一道是最大循环次数。整体循环超过3~5轮就直接终止,把当前已生成的最新正文作为最终答案,或者干脆告诉用户“该问题暂时无法可靠回答”。第二道是反思次数限制。如果连续两轮都触发REFLECT却没有实质修改,我选择忽略第二次反思,强制让模型进入正常输出。第三道是token预算。每一轮生成限定输出长度,避免模型在同一轮里无限续写。

这些限制本质上是系统设计里的“安全阀”。没有安全阀的自主循环,看起来炫,跑起来吓人。

4. 模型靠什么判断该进入哪个分支

4.1 模型其实有“自知之明”,只是不明显

一个经常被忽略的事实是:大模型内部对“自己是否知道答案”是有一定感知能力的,但这种感知不会主动转化成一个显式动作。你直接问“你确定吗?”,它会给出一个置信度式的回答;你让它“用自己的话说一遍”,它会犹豫;但如果你不给它输出这种犹豫的机制,它就直接生成下去了。

特殊tags实际上是把这种“内部犹豫感”变成一个可被捕获的外部事件。模型不是真的在反思,它是在被你设置的few-shot示例和触发条件引导,“觉得”自己该反思了。这个“觉得”准确率有多高?取决于提示词质量和任务类型,不是万能药,但比完全没有强很多。

4.2 工程上可用的自评信号

如果你希望模型在“该反思”和“不该反思”之间更稳定,可以考虑叠加几个工程信号,而不是只靠输出tag。

  • token概率:如果生成下一段时,关键token的logprob很低,说明模型自己也没底。你可以把平均logprob低于某个阈值当作一次隐式“反思请求”。
  • 上下文覆盖率:在生成前先把用户问题改写成几个子问题,用检索器判断子问题是否能从现有资料中全部找到答案。没有覆盖到的子问题,就是需要检索的证据。
  • 矛盾检测:让另一个LLM对“当前输出”和“外部资料”做一致性打分,分数过低时强制触发反思。

这些信号不一定都要上生产,但如果在测试集上发现模型频繁出现“该检索不检索”的情况,可以先用“上下文覆盖率”做硬兜底。

4.3 减少误判的提示语设计

模型误判主要有两种。一种是过度反思:碰到稍微难一点的问题就“REFLECT”,最后变成一段罗圈话。另一种是拒绝检索:即使提示了可以输出SEARCH,模型仍然强行编造。

针对过度反思,我在提示里加了一条:“如果反思并不会改变最终答案,就不要反思。稳健不等于无限自我怀疑。”针对拒绝检索,我会在few-shot示例里放一个模型明显不知道的近期事件,让它完整走一遍SEARCH流程,提高唤醒率。

提示词能解决一部分问题,但不要期望一次调好。这是一个需要反复迭代阈值和案例的过程。

5. 一个最小可运行的参考实现

5.1 骨架函数与状态流

下面是我在Demo里用的一套Python伪代码,去掉了具体模型SDK细节,逻辑可以直接平移到你自己的工程里。整体流程是:生成 → 解析tag → 行动分支 → 记录状态 → 循环或返回。

5.2 核心代码:生成、解析、路由

from typing import List, Dict, Optional ACTION_TAGS = { "continue": "<|CONTINUE|>", "reflect": "<|REFLECT|>", "search": "<|SEARCH|>", "end": "<|END|>", } STOP_STRINGS = list(ACTION_TAGS.values()) SYSTEM_PROMPT = """你是业务助手。在回答过程中,如果缺少关键信息,输出 <|SEARCH|>;如果发现之前回答有矛盾或重大不确定性,输出 <|REFLECT|>;如果回答还需要继续,输出 <|CONTINUE|>;如果认为回答已经结束,输出 <|END|>。除了标记以外的内容才会被用户看到。""" def llm_chat(messages: List[Dict], stop: Optional[List[str]] = None) -> str: # 这里对接你实际使用的模型服务,返回完整生成文本 # 注意:为了演示,这里省略了流式处理和tokenizer细节 return do_llm_completion(messages=messages, stop=stop) def parse_action(text: str) -> Dict: best_idx = len(text) best_tag = None for action, tag in ACTION_TAGS.items(): idx = text.find(tag) if idx != -1 and idx < best_idx: best_idx = idx best_tag = action if best_tag is None: return {"content": text, "action": "end"} content = text[:best_idx].strip() rest = text[best_idx + len(ACTION_TAGS[best_tag]):] return {"content": content, "action": best_tag, "payload": rest.strip()} def build_search_query(history: List[Dict]) -> str: history = history + [{"role": "user", "content": "请只输出检索query,不要解释。"}] return llm_chat(history).strip() def build_reflection(history: List[Dict]) -> str: history = history + [{"role": "user", "content": "请审视上一条输出中的矛盾或证据不足之处,只输出反思笔记。"}] return llm_chat(history).strip() def retrieve(query: str, top_k: int = 3) -> List[str]: # 这里接入你的向量库、倒排索引或搜索服务 return query_retriever(query, top_k=top_k) def agent_loop(user_query: str, max_steps: int = 5) -> str: history = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] final_answer = "" reflection_note = "" retrieved_docs = [] for step in range(max_steps): output = llm_chat(history, stop=STOP_STRINGS) parsed = parse_action(output) if parsed["content"]: if final_answer: final_answer += "\n" + parsed["content"] else: final_answer = parsed["content"] action = parsed["action"] if action == "end": return final_answer if action == "continue": continue if action == "reflect": reflection_note = build_reflection(history) history.append({"role": "user", "content": f"反思笔记:\n{reflection_note}\n请结合笔记修正答案。"}) continue if action == "search": query = build_search_query(history) docs = retrieve(query) retrieved_docs.extend(docs) context_block = "\n".join([f"[片段{i + 1}] {d}" for i, d in enumerate(docs)]) history.append({"role": "user", "content": f"外部资料:\n{context_block}\n请基于资料继续回答。"}) continue return final_answer

这个版本刻意省略了流式处理、异常回退、token用量统计等细节,但核心架构已经完整。看到parse_action时你不用纠结它是否完美,关键是它把“模型正文”和“动作信号”干净地拆开了,后面所有逻辑都基于这个干净拆分来写。

5.3 日志与可观测性

别小看日志。Agent循环一旦出了诡异行为,没有日志基本没法排查。我建议每个step都记录以下几样东西:

  • 用户原始输入;
  • 当前step的原始输出;
  • 解析出的action和payload;
  • 反思笔记或检索query;
  • 本轮消耗的token数和延迟;
  • 最终返回的答案。

日志用JSON格式落盘,方便事后回放。我后来很多调优工作都建立在回放日志上,比如发现某个问题连续三轮触发SEARCH,就知道是检索器没返回有效结果,而不是模型乱跑。

6. 实测中踩过的坑和调优

6.1 模型不按规矩输出tag

最头痛的问题,就是模型死活不输出<|SEARCH|>,宁可编造也不调用。我排查后的原因有两个。

第一个是stop字符串影响不够。有些模型对stop参数的理解偏弱,尤其当tag没有作为独立token被注册时。你传一堆字符串,它可能根本不会在对应位置停。解决办法是把tag接入tokenizer,或者使用流式缓冲检测。

第二个是few-shot示例给反了。我最早给的示例都是“模型输出一段长文,然后自然结束”的正例,模型学习到的主路径就是一路输出到<|END|>,根本不会主动切到<|SEARCH|>。后来我在每个示例里都刻意放了至少一个SEARCH的转折点,并把“转折后的效果”展示出来,唤醒率立刻上来了。

6.2 反思过度与“反思空转”

反思过度是另一个高频问题。模型是很有危机感的,只要你告诉它可以“反思”,它在稍微复杂的任务上就会频繁触发,特别是在多步逻辑推理时。结果就是每步都反思,最后语义被改得面目全非,或者陷入“反思—修改—再反思”的循环。

反思空转更隐蔽:模型输出了反思笔记,但笔记内容非常泛,比如“我需要更仔细地思考”,然后修正后的答案和原来基本一致。这等于白烧了很多token。

我的对策就是把反思次数限定死,比如最多2次。第一轮发现矛盾就反思,第二轮如果模型又要求反思,系统直接忽略,强制用现有内容生成答案。同时在反思引导语里要求:“如果你的反思不会带来实质内容变化,请直接输出‘无需修改’。”这句话对空转有奇效。

6.3 检索结果注入的时机和长度

检索结果不是越多越好。我把top_k调到10以后,模型的答案质量反而下降,出现了明显的“复制粘贴感”,参与其中还会把不相关的片段也吸收进答案,造成信息密度反而变低。

我现在使用的策略是:每次只塞3条最相关片段,每条尽量控制在150字以内。检索结果只保留“能直接回答问题”的核心句子,其余砍掉。如果3条不够,可以让循环到下一轮的SEARCH再补一次,但绝不在单轮内堆很多资料。

还有一个容易被忽略的点:检索结果和模型内部知识冲突时,模型总是倾向于认为自己记忆里的内容更正确。我这边的做法是在外部资料段前面加一句固定说明:“外部资料是最新且经过验证的;如果与你的记忆冲突,优先采用外部资料。”效果改善明显。

6.4 评估一个Agent循环,不能只看准确率

跑测试集时,我最开始只盯着“最终答案对不对”,结果发现一个漏洞:系统在有些问题上偷懒,直接返回空答案,准确率虽然没跌,但用户体验极差。后来纳入几个辅助指标:

  • 检索触发率:太高说明prompt识别有问题,太低说明模型又在裸编;
  • 反思轮数分布:正常任务里反思比例应该在可控范围,频繁反思说明任务拆解或提示需要调整;
  • 平均响应耗时与token成本:tag循环会显著增加调用次数,这个数字必须公开透明;
  • 最终答案的事实支持率:用一个小验证器判断最终答案里的关键断言是否能在检索片段或上下文里找到直接证据。

这套综合指标才让我真正判断出“系统是变好了还是变贵了”。

7. 改造与移植:从问答到更多Agent形态

7.1 接入知识库时的混合检索与重排

如果你想让这个循环接上真正的知识库,建议不要只用向量相似度去搜。我现在用的是混合检索,先做向量召回和关键词召回两个通道,然后用一个融合排序把两路结果合并。融合排序时会考虑文本长度惩罚、来源可信度、时间新鲜度等。

接知识库后,还要注意一个循环陷阱:如果模型第一轮SEARCH没有拿到有效资料,它会立刻第二轮SEARCH,如果还没有又会第三轮,白白浪费调用。我在循环条件里加了一个“无效检索判定”——如果top_k片段里的最高相似度分数低于某个值,就认为检索失败,直接告知模型“外部资料中未找到相关信息,请基于已有知识谨慎回答”,而不是再给它一次检索机会。

7.2 多轮对话中tag状态的组织

多轮对话比单轮复杂得多。单轮状态下,你只需要清空final_answer重新跑;多轮时,用户可能会在下一轮追问“你刚才说的那个数据来源是什么”。如果之前的状态不保留,模型根本不知道“刚才说的那个”指什么。

我现在的做法是把整场对话的所有final_answer摘要成一段“运行摘要”放进历史,同时保留最近两轮的检索片段和反思笔记。用户问新问题时,模型能看到之前的决策过程,不至于失忆。

7.3 我的实际体会与迁移建议

这套机制帮我解决了一个很实际的问题:在内部某业务场景里,用户经常问一些训练数据没覆盖的新政策、新产品资讯。早期版本要么给出过时结论,要么老实地承认不知道但给不出补救。植入tag循环之后,系统在“发现自己不知道”这件事上变得越来越主动,用户可感知的回答可靠性确实高了一截。

不过我也要泼一盆冷水。特殊tags方案不是银弹,它对模型基础能力有要求。如果模型本身逻辑推理弱、上下文衔接差,那你只是在给一个不靠谱的系统装上更精致的方向盘。开始改造前,最好先确认你的底层模型在“带停止字符串生成”这件事上表现稳定,并且给足测试集泛化验证的时间。

另外一个值得尝试的扩展方向,是把这套tag决策思路扩展到“工具选择”上。比如新增<|CALC|>标签进入计算器分支、新增<|DRAW|>标签进入图表生成分支。本质上都是在生成流的决策点插入可外部接管的路标,这套方法论是通用的。

最后分享一个我在实际调优中反复验证的体会:tag系统不要做得太重。一开始我加了一堆元信息标签,比如<|CONFIDENT|>、<|UNSURE|>、<|SOURCE_CHECK|>,结果模型经常误判,解析成本飙升。把标签数量控制在3个左右,很多麻烦会自然消失。如果某类问题真的需要更多动作,那就拆成多个模型任务,而不是硬堆标签。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进