多模型研讨 Agent 实战:让 GPT、Claude 互相挑错(附 3 个坑)

0 阅读6分钟

多模型研讨 Agent 实战:让 GPT、Claude、Qwen 交叉验证(LangGraph + FastAPI)

摘要:让多个异构大模型先独立作答、再互相挑错、最后由首席模型总结。本文讲三阶段怎么落地,以及开发过程中踩到的三个坑——深度思考模型超时、SSE 乱序顶掉内容、研讨记录丢气泡。

一、为什么要让模型互相挑错

单个模型回答问题时,「自我检查」几乎是无效的。

原因很简单:同一个模型、同一套权重、同一批训练数据,复查十遍也还是同一个盲区。你让它「再看看有没有问题」,它只会换个说法把原答案重复一遍。

换一个思路:让不同厂商的模型同时回答,然后互相挑错。

GPT、Claude、Qwen、DeepSeek 的训练数据、对齐策略、知识截止时间都不一样。它们的分歧本身就是一个信号——分歧出现的地方,往往就是原答案最不可靠的地方。

这就是多模型研讨(Multi-Model Debate)的基本假设。我把它做成了一个完整可跑的项目。

二、整体架构

三个阶段是线性推进的:

  1. 第一轮 · 独立作答:所有副模型并行回答,彼此看不到对方,保证观点独立
  2. 第二轮 · 交叉研讨:每个模型读到其他人的答案,补充或反驳
  3. 第三轮 · 首席总结:主模型综合所有观点,剔除错误信息,输出一份结论

编排用 LangGraph 的 StateGraph,三个阶段是三个节点;模型调用是 IO 密集型,所以并发用线程池;进程内的请求上下文用 contextvars 隔离(Trace ID、LLM 客户端、模型名)。

整个流程通过 SSE 推给前端,三个阶段的产出按轮次分块渲染。

三、第一轮:并行独立作答

关键点是互不可见。每个专家拿到的 prompt 里只有原始问题,没有别人的答案:

def run_first_round(model_name):
    client = get_client_for_model(model_name)
    prompt = f"你是一名专家,请针对以下问题,给出你独立、专业的见解。\n问题:{query}"
    resp = client.chat.completions.create(
        model=resolve_model_id(model_name),
        messages=[{"role": "user", "content": prompt}],
        max_tokens=2048
    )
    return model_name, resp.choices[0].message.content

调度用 asyncio.as_completed,谁先返回谁先推给前端,不用等最慢的那个:

tasks = [run_first_round(m) for m in staff_models]
for future in asyncio.as_completed(tasks):
    name, content = await future
    yield sse({'event': 'speak', 'stage': 1, 'model': name, 'content': content})

四、第二轮:交叉研讨

每个模型拿到其他人的答案(不含自己的),要求指出漏洞或补充:

context_str = ""
for m, ans in first_round.items():
    if m != model_name:
        context_str += f"【{m} 的观点】:\n{ans}\n\n"

prompt = (f"针对问题:{query}\n\n其他专家的第一轮观点如下:\n{context_str}\n\n"
          f"请你基于以上信息,发表你的补充意见,或者指出他们可能存在的漏洞。")

实际跑下来的观察:大约 1/5 的发言是「我同意前面那位」,但只要有一个人指出问题,往往就是真问题。

五、第三轮:首席总结

把所有人的观点拼起来交给主模型,要求它去伪存真,并且不要提及专家名字,直接给结论——否则输出会变成「A 说了什么、B 说了什么」的会议纪要,用户要的是答案。

六、踩坑一:深度思考模型超时

接入豆包 Seed 2.1 之后,简单问一句「现在几点」都要 20~60 秒,直接顶穿 LLM_TIMEOUT(默认 60 秒),前端只能看到一个错误。

原因是这一代模型默认开启深度思考,会在正式回答前先生成一大段推理内容。关掉它:

{"thinking": {"type": "disabled"}}

效果对比(同样是问「回复两个字:你好」):

默认关闭思考
耗时20~60s1.65s
completion_tokens992

我把这个参数做成了注册表的一个可选字段,加模型的时候顺手写进去,不用改任何代码:

def get_model_extra_body(model_name: str) -> dict:
    config = get_model_registry().get(model_name) or {}
    extra = config.get("extra_body")
    return extra if isinstance(extra, dict) else {}

调用时透传:

extra = get_model_extra_body(model_name)
kwargs = {"extra_body": extra} if extra else {}
resp = client.chat.completions.create(..., **kwargs)

七、踩坑二:SSE 乱序把内容顶掉

现象:研讨到第二轮,第一轮的内容会突然消失一部分。

原因是前端靠一个 currentStage 变量记录「现在是第几轮」,收到内容就往对应的容器里写。但 SSE 帧到达顺序不保证,一旦第二轮的分隔帧先到,后续第一轮的内容就被写进了第二轮的容器——看起来就是「被顶掉」。

解法:不要依赖到达顺序,让每一帧自己带上下标。

# 后端:每个 speak 帧都带上 stage
yield sse({'event': 'speak', 'stage': 1, 'model': name, 'content': content})
// 前端:用帧里的 stage 定位轮次
const round = payload.stage || currentRound;
const target = ensureBubble(round, payload.model);
typewriterEffect(target, payload.content);

这个坑的教训是:流式场景下,任何「靠全局状态变量推断位置」的写法都是脆的,状态必须跟着数据走。

八、踩坑三:研讨记录丢气泡

研讨结束后,整场讨论被拼成一大段 Markdown,作为一条消息存库:

full_history = "### — 第一轮 —\n\n**gpt-6**:\n...\n\n### 👑 首席总结\n"
save_message(db, session_id, "assistant", full_history)

问题:实时渲染时有专家头像、模型名标签、轮次分隔线,一旦切到别的会话再切回来,全部消失,只剩一大段纯文本。

改成每位专家单独存一条,元信息塞进已有的 thought_process 字段(不用改表结构):

for m, ans in state["first_round"].items():
    save_message(db, session_id, "assistant", ans, f"GROUP_SPEAK:1:{m}")
for m, ans in state["discussion"].items():
    save_message(db, session_id, "assistant", ans, f"GROUP_SPEAK:2:{m}")
save_message(db, session_id, "assistant", summary, f"GROUP_SUMMARY:{chief_model}")

前端读历史时按标记还原成同样的 DOM:

if (tp.startsWith('GROUP_SPEAK:')) {
    const [, stage, model] = tp.split(':');
    histSpeak(stage, model, msg.content);
} else if (tp.startsWith('GROUP_SUMMARY:')) {
    histSummary(model, msg.content);
} else {
    addMessage(msg.content, msg.role);
}

九、实测数据

本地跑的单次调用耗时(不含前端渲染):

场景耗时
查时间(走工具)1.52s
算数 123*456(走工具)1.09s
一场完整研讨(3 模型 / 4 次发言 / 含总结)19.3s

研讨的耗时主要取决于最慢的那个专家,因为每一轮都要等所有人说完才能进下一轮。

十、还没做的

  1. 量化评测:目前只有主观验证,没有跑基准。下一步想用 HotpotQA + 事实核查集,比较「单模型」 vs 「研讨模式」在相同 token 预算下的准确率。
  2. 研讨过程流式:现在是每个阶段结束才推送整段内容,理想状态是边生成边推。
  3. 动态选人:根据问题类型自动决定用几个专家、用谁。

项目地址:github.com/3038816077/…(MIT,Docker 一条命令能跑)

如果这个思路对你有用,欢迎 star 支持一下。