让智能体真正接住追问

智能体架构追问多轮对话ReAct软件架构

很多智能体产品的第一版,最后都会长得像一个“一次性任务系统”:用户提交需求,系统规划、执行、生成交付结果,然后任务结束。

这个模型适合离线批处理,也适合确定性较强的专用流程。但一旦用户看到结果后继续问“为什么是这个结论”“帮我展开第三点”“换一个口径再算一次”“把这个结果整理成邮件”,系统就会暴露一个架构问题:它到底是在回答一个新问题,还是在延续上一个任务?

追问功能麻烦的地方不在输入框,也不是多存一条消息。真正麻烦的是:系统要把“一次性 run”升级成“可续写 session”。这篇文章记录一次实际改造后的设计思路,重点放在通用智能体和专用智能体都绕不开的运行时问题上。

示例代码来自真实项目的核心设计抽象,但已经去除业务字段、业务工具和业务提示词,只保留通用架构。

主流智能体给出的信号

如果只从项目内部看,追问功能很容易被低估成“多轮对话”。但看 OpenAI Deep Research、Manus、Kimi 系 agentic 模型和相关论文,会发现它更接近运行时能力:系统在执行中接收新的观察、新的约束,有时还要接住新的目标。

OpenAI Deep Research 不是普通问答。它会在网上做多步研究,查找、分析、综合来源,并根据中途看到的信息调整方向。OpenAI 在 2026 年 2 月的更新里还提到,用户可以实时看进度,并用追问或新来源中断、细化任务。这里的追问已经不是“任务结束后补一句”,而是研究流程里的一次干预。

Manus 的表达更偏产品。它把建站、幻灯片、设计、游戏、浏览器操作、Wide Research、API 等能力放在同一个工作台里。这样的产品形态会逼着系统承认一件事:用户不会一直按固定流程提需求。他可能先让智能体查资料,下一句就让它做页面,再下一句要求改成演示稿。

Kimi OK Computer 这类让模型使用电脑完成任务的方向,以及 Kimi K2、Kimi K2.5 技术报告里反复出现的 agentic intelligence、环境交互、工具使用、软件工程任务、多模态 agentic model 和 Agent Swarm,也在说明类似的问题:复杂任务不是单条 prompt 能稳定吃下的。用户补充、工具观察、子任务拆解和并发执行,最后都要回到运行时设计。

论文里也能找到对应的设计线索:

来源对追问架构的启发
ReAct推理和行动应该交替发生,观察结果会改变下一步行动。用户追问可以视为新的 observation。
Reflexion语言反馈和反思记忆可以改进后续尝试。追问后的 session memory 不只是聊天记录,而是可检索的经验。
Toolformer模型需要学会何时调用工具、传什么参数、如何吸收工具结果。追问分类本质上也是“是否需要再调用工具”的决策。
Generative Agents观察、记忆、反思、规划共同构成长期行为。session/run 分离正是工程化落点。
Kimi K2/K2.5Agentic 能力需要真实或合成环境交互训练,多工具、多任务、多模态协同会成为常态。

所以我不会把追问归到“消息系统功能”里。它更像智能体控制平面的一部分。

追问不是普通聊天

很多团队会把追问实现成普通 chat:用户追加一条消息,模型读历史消息,然后返回文本。

这种做法只能解决最简单的“解释一下刚才内容”。智能体场景里的追问通常会分成几类:

追问类型用户意图系统动作
解释型解释已有结果、依据、过程读取历史产物并直接回答
展开型对某个章节、数据、结论继续深入使用历史上下文生成增量任务
修订型修改口径、约束、格式或交付形态基于旧 run 创建新 DAG 版本
补参型回答系统之前提出的澄清问题填充缺失参数并继续原任务

如果把这些都当成 chat,问题很快会冒出来:

  1. 历史产物无法被结构化引用,只能塞进 prompt。
  2. 需要重新执行工具时,没有任务版本和依赖关系。
  3. 用户连续追问时,旧任务、新任务、补参任务容易交叉执行。

所以追问要进运行时,不能只停在 UI 层的一次消息发送。

换成产品语言,Deep Research 式任务要能在研究过程中接收新来源和新约束;Manus 式任务要能跨浏览器、文件、网页和交付物继续推进;Kimi 系 agentic model 则把工具交互和环境反馈放到了能力建设里。工程上要回答的问题很朴素:用户又说了一句话,系统到底怎么调度?

改版前 run 完成后就断了

早期架构中,run 是最重要的实体。用户创建一次任务,就创建一个 run;run 内部有 tasks、events、artifacts、messages。

flowchart LR
  U[User Input] --> R[Run]
  R --> P[Plan / Compile]
  P --> T[Tasks]
  T --> A[Artifacts]
  A --> F[Final Answer]

这套结构并不差,问题出在一个默认假设:run 完成后,生命周期也结束了。

因此旧逻辑通常长这样:

async function postRunMessage(runId: string, message: UserMessage) {
  const run = await loadRun(runId);
  await insertMessage(runId, "user", message);

  if (isTerminal(run.status)) {
    if (run.inputs.chatMode) {
      return appendChatTurn(run, message);
    }

    await insertMessage(runId, "assistant", {
      text: "这次任务已经完成。可以继续提出新的需求,系统会接着处理。",
    });
    return { ok: true, skipped: true };
  }

  if (await hasPendingNeedInput(runId)) {
    return submitNeedInput(run, message);
  }

  return recompileFromMessages(run);
}

这段逻辑不算 bug。它只是少了“完成后继续工作”这个模型:

场景旧模型表现
普通聊天 run 已完成可以继续创建 chat task
Agent run 正在等待补参可以填参后继续编译
Agent run 已完成后用户追问只能收消息,无法真正续写
用户连续发送两条追问缺少修订版本和并发保护

结果就是,系统可以处理“执行中补充信息”,却接不住“看完结果后继续工作”。

Session 是长期上下文

第一个改动,是把 session 提到一等实体的位置。

session 保存用户和智能体围绕一个目标形成的长期上下文;run 是其中一次执行回合;task 是回合里的可执行节点。

flowchart TD
  S[Agent Session] --> R1[Run: turn 1]
  S --> R2[Run: turn 2]
  S --> R3[Run: turn 3]
  R1 --> T1[Tasks]
  R1 --> A1[Artifacts]
  R2 --> T2[Incremental Tasks]
  R2 --> A2[New Artifacts]
  S --> M[Session Memory]

抽象后的数据模型大致如下:

type AgentSession = {
  id: string;
  ownerId: string;
  title: string;
  status: "active" | "archived";
  workspaceKey: string;
  lastRunId?: string;
  memorySummary?: string;
  memoryVersion: number;
  lastActiveAt: number;
};

type AgentRun = {
  id: string;
  sessionId: string;
  parentRunId?: string;
  turnIndex: number;
  status: "planned" | "queued" | "running" | "completed" | "failed" | "canceled";
  inputText: string;
  dagVersion: number;
  generalInputs: Record<string, unknown>;
};

type SessionMemory = {
  id: string;
  sessionId: string;
  sourceRunId: string;
  kind: "summary" | "user_preference" | "delivery";
  content: { text: string };
  importance: number;
  createdAt: number;
};

创建 run 时,先准备会话,不再直接创建一个孤立任务:

async function prepareAgentSession(params: {
  requestedSessionId?: string;
  runId: string;
  input: string;
  ownerId: string;
  previousRunId?: string;
}) {
  const existing = params.requestedSessionId
    ? await findSession(params.requestedSessionId, params.ownerId)
    : null;

  const sessionId = existing?.id ?? params.requestedSessionId ?? params.runId;
  const workspaceKey = existing?.workspaceKey ?? sessionId;

  if (!existing) {
    await insertSession({
      id: sessionId,
      ownerId: params.ownerId,
      title: params.input.slice(0, 120),
      status: "active",
      workspaceKey,
      lastRunId: params.runId,
    });
  }

  const turnIndex = await nextTurnIndex(sessionId);

  return {
    sessionId,
    workspaceKey,
    turnIndex,
    parentRunId: params.previousRunId ?? existing?.lastRunId ?? null,
  };
}

表面上只是多了一张表,系统边界其实变了。用户追问时,不是在“复活一个已完成 run”,而是在同一个 session 里开始新回合,或者对当前 run 做一次受控修订。

这和 Generative Agents、Reflexion 里的记忆思想能对上:长期行为需要记忆,但记忆不等于把聊天记录全塞回 prompt。工程上要把历史交付、用户偏好、失败原因、中间结论压缩成 session memory,再按需取回。

记忆写入函数可以很朴素:

async function appendSessionMemoryFromRun(params: {
  runId: string;
  sessionId: string;
  text: string;
  kind?: "summary" | "delivery" | "preference" | "error";
  importance?: number;
}) {
  const clipped = params.text.length > 3000
    ? `${params.text.slice(0, 3000)}...`
    : params.text;

  await insertSessionMemory({
    id: newId(),
    sessionId: params.sessionId,
    sourceRunId: params.runId,
    kind: params.kind ?? "summary",
    content: { text: clipped },
    confidence: 1,
    importance: params.importance ?? 0.7,
  });

  await updateSession(params.sessionId, {
    memorySummary: clipped,
    lastRunId: params.runId,
    memoryVersionIncrement: 1,
  });
}

真正要控制的是写入边界。不要把所有 token 都写进 memory,只写以后追问可能用到的东西,比如最终结论、用户确认过的口径、工具失败原因、数据来源摘要、交付物索引。

追问入口先记录观察

对于 ReAct 类智能体,用户追问可以被视为一种新的 observation。

后端入口不要马上同步生成答案,而应该做四件事:

  1. 保存用户消息。
  2. 写入一条 user_follow_up observation 事件。
  3. 插入一条 assistant acknowledgement,让前端立即有反馈。
  4. 把 run 状态切回可调度状态,异步触发修订。

抽象代码如下:

async function acceptAgentFollowUp(runId: string, body: PostMessageBody) {
  const run = await requireRunAccess(runId);
  const userText = textFromMessage(body.content);
  const ts = Date.now();

  await insertMessage(runId, "user", { text: userText });

  await insertRunEvent({
    runId,
    type: "react_observation",
    payload: {
      kind: "user_follow_up",
      message: userText,
      sessionId: run.generalInputs.sessionId ?? runId,
      receivedAt: ts,
    },
    createdAt: ts,
  });

  await insertMessage(runId, "assistant", {
    text: "已收到追问,系统将结合上一次交付结果和当前上下文继续处理。",
  });

  await insertRunEvent({
    runId,
    type: "progress",
    payload: {
      phase: "react_revision",
      message: "正在结合补充内容更新执行计划",
      sessionId: run.generalInputs.sessionId ?? runId,
    },
    createdAt: ts + 1,
  });

  await updateRunStatus(runId, "planned");

  scheduleReactRunRevision({
    runId,
    modelConfig: body.modelConfig,
  });

  return { ok: true, accepted: true, revision: "scheduled" };
}

这里有一个关键点:追问不是直接塞给最终回答模型,而是作为事件进入运行时。这样系统后续可以选择直接回答、检索材料、调用工具、重建 DAG 或生成新交付。

因此 ReAct 比“一次性规划”更适合追问。ReAct 的核心不是暴露模型内部推理,而是把推理、行动、观察拆成可循环的控制结构。来自工具的 observation、来自用户的 follow-up、来自运行时的错误恢复,都可以进入同一个循环。

追问意图决定执行路径

追问进入运行时后,智能体需要判断“要不要动工具”。

我的设计习惯是把它分成三层:

flowchart TD
  Q[Follow-up Question] --> C{Intent Classifier}
  C -->|解释已有结果| D[Direct Answer]
  C -->|补充缺失信息| N[Need Input Resume]
  C -->|需要新证据或新产物| R[ReAct Revision]
  R --> G[Generate Incremental DAG]
  G --> E[Execute New Tasks]
  E --> F[Update Delivery]

分类器可以是规则 + LLM 的组合。规则负责高置信度分流,LLM 负责开放语义。

type FollowUpIntent =
  | { type: "direct_answer"; reason: string }
  | { type: "resume_need_input"; answers: Record<string, unknown> }
  | { type: "revise_run"; objective: string; requiresTools: boolean };

async function classifyFollowUp(params: {
  question: string;
  runSummary: string;
  latestDelivery: string;
  pendingQuestions: NeedInputQuestion[];
}): Promise<FollowUpIntent> {
  if (params.pendingQuestions.length > 0) {
    const answers = matchNeedInputAnswers(params.question, params.pendingQuestions);
    if (Object.keys(answers).length > 0) {
      return { type: "resume_need_input", answers };
    }
  }

  if (/^(解释|为什么|依据|这是什么意思)/.test(params.question)) {
    return { type: "direct_answer", reason: "question targets existing result" };
  }

  const decision = await llmJson({
    system: "判断用户追问是否需要调用工具或重新生成产物,只输出 JSON。",
    user: {
      question: params.question,
      runSummary: params.runSummary,
      latestDelivery: params.latestDelivery.slice(0, 4000),
    },
    schema: followUpIntentSchema,
  });

  return decision;
}

注意这里不要让分类器直接生成最终答案。分类器只做路由,避免一个 prompt 同时承担“判断、执行、交付”三种职责。

这个分类层对应的是 Toolformer 一类工作的工程化版本:模型或系统需要判断何时使用外部工具。追问里最危险的错误不是“回答得不够长”,而是本该重新检索、重新计算或重新执行,却只根据旧上下文编了一个看似合理的解释。

增量 DAG 不覆盖旧任务

追问经常会触发新任务,例如重新检索、重新计算、重写交付、导出新格式。

这里最容易犯的错误,是直接修改旧 tasks。这样会破坏审计链路,也会让前端不知道哪些结果来自第一次执行,哪些来自追问。

更稳的方式是引入 dagVersion

type DagNode = {
  stepId: string;
  title: string;
  cellId: string;
  args: Record<string, unknown>;
  dependsOn: string[];
  dagVersion: number;
};

async function insertRevisionDag(params: {
  runId: string;
  previousDagVersion: number;
  nodes: DagNode[];
}) {
  const nextDagVersion = params.previousDagVersion + 1;
  const baseOrder = await countTasks(params.runId);

  for (const [index, node] of params.nodes.entries()) {
    await insertTask({
      id: newId(),
      runId: params.runId,
      orderIndex: baseOrder + index,
      title: node.title,
      cellId: node.cellId,
      status: "pending",
      payload: {
        type: "cell",
        stepId: node.stepId,
        cellId: node.cellId,
        args: node.args,
        dependsOn: node.dependsOn,
        dagVersion: nextDagVersion,
      },
    });
  }

  await updateRun(params.runId, {
    status: "queued",
    dagVersion: nextDagVersion,
  });
}

执行器只执行当前版本的任务:

def task_dag_version(task):
    payload = task.get("payload") or {}
    value = payload.get("dagVersion")
    return int(value) if value else None


def tasks_for_dag_version(tasks, dag_version):
    if dag_version is None:
        return tasks
    return [task for task in tasks if task_dag_version(task) == dag_version]

这解决了两个问题:

  1. 旧产物可以保留,用于审计和上下文引用。
  2. 新追问可以只执行增量任务,不必从头跑完整流程。

连续追问下的并发控制

真实用户不会等系统慢慢执行完再提下一个问题。他可能连续发送:

  1. “把第三点展开。”
  2. “顺便加上风险。”
  3. “不要太长,按表格输出。”

如果没有并发控制,系统可能插入三批任务,互相覆盖状态,最终执行器看到重复 stepId 或混合版本 DAG。

一个实用兜底是:执行器按 stepId 保留最新任务行。

def collapse_duplicate_dag_steps(tasks):
    latest_by_step = {}
    passthrough = []

    for task in tasks:
        payload = task.get("payload")
        if not isinstance(payload, dict):
            passthrough.append(task)
            continue

        step_id = str(payload.get("stepId") or "").strip()
        if not step_id:
            passthrough.append(task)
            continue

        latest_by_step[step_id] = task

    collapsed = [*passthrough, *latest_by_step.values()]
    return sorted(collapsed, key=lambda task: int(task.get("orderIndex") or 0))

但这只是执行器兜底。更完整的设计应该在 API 层加修订锁:

async function scheduleRevisionSafely(runId: string, buildRevision: () => Promise<void>) {
  const lockKey = `run:${runId}:revision`;
  const acquired = await tryAcquireLock(lockKey, { ttlMs: 30_000 });

  if (!acquired) {
    await markRunHasPendingFollowUp(runId);
    return { scheduled: false, reason: "revision_in_progress" };
  }

  try {
    await buildRevision();
  } finally {
    await releaseLock(lockKey);
  }

  if (await consumePendingFollowUpFlag(runId)) {
    return scheduleRevisionSafely(runId, buildRevision);
  }

  return { scheduled: true };
}

追问的用户体验看起来是“发消息”,工程上则是“多版本执行计划的串行化修订”。

建议追问应该成为产物元数据

很多智能体会在最终回答末尾输出“建议继续追问的问题”。这是一个好体验,但如果只放在 Markdown 正文里,前端很难稳定渲染成按钮,也很难避免它污染正式交付内容。

一个折中方案是:模型可以先在正文末尾生成这个章节,后端或前端再抽取成结构化字段,并从正文中剥离。

function extractNextQuestionsFromText(text: string): {
  text: string;
  nextQuestions: string[];
} {
  const lines = text.split("\n");
  const sectionIndex = lines.findIndex((line) =>
    /^#{0,6}\s*(?:\d+\s*[.、]\s*)?建议继续追问的问题\s*$/.test(line.trim()),
  );

  if (sectionIndex < 0) {
    return { text, nextQuestions: [] };
  }

  const before = lines.slice(0, sectionIndex).join("\n").trim();
  const section = lines.slice(sectionIndex + 1).join("\n");
  const seen = new Set<string>();
  const nextQuestions: string[] = [];

  const pushQuestion = (value: string) => {
    const clean = value
      .replace(/^[-*]\s*/, "")
      .replace(/^["“”]+|["“”]+$/g, "")
      .trim();

    if (!clean || clean.length < 8 || seen.has(clean)) return;
    seen.add(clean);
    nextQuestions.push(clean);
  };

  for (const rawLine of section.split("\n")) {
    const line = rawLine.trim();
    if (!line) continue;

    const quoted = line.match(/[“"]([^”"]{8,})[”"]/);
    if (quoted?.[1]) {
      pushQuestion(quoted[1]);
      continue;
    }

    const afterColon = line.match(/^[^::]{2,30}[::]\s*(.+)$/)?.[1];
    if (afterColon) {
      pushQuestion(afterColon);
      continue;
    }

    if (/[??]$/.test(line)) {
      pushQuestion(line);
    }
  }

  return {
    text: before || text,
    nextQuestions: nextQuestions.slice(0, 6),
  };
}

最终 artifact 应该长这样:

type FinalDeliveryArtifact = {
  kind: "final_delivery";
  data: {
    text: string;
    nextQuestions?: string[];
  };
};

前端渲染时优先读结构化字段:

function artifactNextQuestions(artifact: ArtifactRecord, fallbackText: string) {
  const value = artifact.data?.nextQuestions;

  if (Array.isArray(value)) {
    return value.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 6);
  }

  return extractNextQuestionsFromText(fallbackText).nextQuestions;
}

然后把每个问题渲染成按钮。用户点击后,不是立刻请求模型,而是把问题回填到输入框,让用户可以编辑:

<MessageBubble
  message={message}
  artifacts={artifacts}
  onFollowUpQuestion={(question) =>
    setFollowUpDraft({ text: question, version: Date.now() })
  }
/>

<PromptInput
  placeholder="发送消息给智能体"
  draftOverride={followUpDraft ?? undefined}
  disabled={runIsExecuting}
/>

这个细节很重要:建议问题是系统建议,不是用户确认。点击后允许编辑,用户才仍然掌握意图表达权。

专用智能体和通用智能体的不同取舍

追问能力在专用智能体和通用智能体里的设计侧重点不同。

维度专用智能体通用智能体
追问范围围绕固定交付物或固定流程可能转向新目标、新工具、新格式
上下文结构强 schema,字段明确弱 schema,依赖 session memory
补参方式表单化、字段化自然语言补充 + 自动抽取
重新执行常按固定阶段重跑常生成增量 DAG
建议问题可按模板生成应由最终回答模型或反思模型生成
风险过度限制用户表达上下文漂移、任务边界发散

可行的取舍是:

专用智能体优先把追问做成“交付物上的操作”。例如解释、展开、改格式、重算某个字段、补充某个章节。它的优势是稳定、可测、可审计。

通用智能体优先把追问做成“会话中的新 observation”。系统不要假设用户只是改报告,也可能是在要求打开网页、查文件、写代码、生成图片或比较多个方案。

两者可以共享底层会话模型,但不要共享同一套追问策略。

如果把主流产品作为参照,Deep Research 更接近“深度研究型专用 Agent”,它的追问重点是新来源、新证据、新口径和报告修订;Manus 更接近“通用工作台 Agent”,它的追问重点是跨工具继续完成工作;Kimi K2/K2.5 代表的则是“模型本身朝 agentic 能力训练”的方向。三类系统的共同点,是都不满足于单轮回答。

几个设计原则

第一,追问要进入运行时,而不是只进入 prompt。

如果追问只作为聊天上下文存在,系统很难做到工具调用、任务版本、审计和恢复。至少要把它记录成 event。

第二,session 和 run 要分开。

run 是一次执行;session 是长期上下文。追问能力真正需要的是 session,不是无限拉长单个 run。

第三,旧产物不要覆盖。

追问生成的新结果应该有新的 artifact 或新的 dagVersion。覆盖旧产物会让用户无法比较,也会让系统丢失因果链。

第四,建议问题要结构化。

把“建议继续追问的问题”从正文中抽取成 metadata,前端才能做稳定交互,正式交付也不会被聊天式尾巴污染。

第五,连续追问必须有并发策略。

用户输入不是队列里完美间隔的事件。修订锁、版本号、重复 step 折叠、pending follow-up 标记,都是追问能力上线后迟早需要的工程设施。

结语

追问功能表面上是一个体验优化,本质上是智能体系统从“任务执行器”向“会话式运行时”演进。

如果只做一个输入框,它会变成普通聊天。如果把它纳入 session、memory、event、DAG version、artifact metadata 和 runtime scheduling,它才会成为真正的智能体能力。

追问应该被看成智能体架构里的第一等行为:用户不是在任务结束后又问了一句话,而是在把新的观察、新的约束、新的目标注入同一个认知过程。系统是否能接住这个变化,决定了它到底是一次性工具,还是可以持续协作的智能体。

参考文献与资料

  1. OpenAI, Introducing deep research, 2025-02-02;文中引用其关于多步研究、网页/文件/Python 工具、实时进度与追问细化的公开说明。
  2. OpenAI, Agents SDK documentation;用于参考 Agent definitions、orchestration、guardrails、results/state、observability、conversation state、background mode、streaming、multi-agent、computer use 等工程概念。
  3. Manus, Manus: Hands On AI;用于参考其“Less structure, more intelligence”产品定位,以及浏览器操作、Wide Research、Web app、AI slides、API 等工具型 Agent 形态。
  4. Shunyu Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, 2022.
  5. Noah Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning, 2023.
  6. Timo Schick et al., Toolformer: Language Models Can Teach Themselves to Use Tools, 2023.
  7. Joon Sung Park et al., Generative Agents: Interactive Simulacra of Human Behavior, 2023.
  8. Kimi Team, Kimi K2: Open Agentic Intelligence, 2025.
  9. Kimi Team, Kimi K2.5: Visual Agentic Intelligence, 2026.

评论

评论由 GitHub Issues 提供支持,需要登录 GitHub。