让智能体真正接住追问
很多智能体产品的第一版,最后都会长得像一个“一次性任务系统”:用户提交需求,系统规划、执行、生成交付结果,然后任务结束。
这个模型适合离线批处理,也适合确定性较强的专用流程。但一旦用户看到结果后继续问“为什么是这个结论”“帮我展开第三点”“换一个口径再算一次”“把这个结果整理成邮件”,系统就会暴露一个架构问题:它到底是在回答一个新问题,还是在延续上一个任务?
追问功能麻烦的地方不在输入框,也不是多存一条消息。真正麻烦的是:系统要把“一次性 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.5 | Agentic 能力需要真实或合成环境交互训练,多工具、多任务、多模态协同会成为常态。 |
所以我不会把追问归到“消息系统功能”里。它更像智能体控制平面的一部分。
追问不是普通聊天
很多团队会把追问实现成普通 chat:用户追加一条消息,模型读历史消息,然后返回文本。
这种做法只能解决最简单的“解释一下刚才内容”。智能体场景里的追问通常会分成几类:
| 追问类型 | 用户意图 | 系统动作 |
|---|---|---|
| 解释型 | 解释已有结果、依据、过程 | 读取历史产物并直接回答 |
| 展开型 | 对某个章节、数据、结论继续深入 | 使用历史上下文生成增量任务 |
| 修订型 | 修改口径、约束、格式或交付形态 | 基于旧 run 创建新 DAG 版本 |
| 补参型 | 回答系统之前提出的澄清问题 | 填充缺失参数并继续原任务 |
如果把这些都当成 chat,问题很快会冒出来:
- 历史产物无法被结构化引用,只能塞进 prompt。
- 需要重新执行工具时,没有任务版本和依赖关系。
- 用户连续追问时,旧任务、新任务、补参任务容易交叉执行。
所以追问要进运行时,不能只停在 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。
后端入口不要马上同步生成答案,而应该做四件事:
- 保存用户消息。
- 写入一条
user_follow_upobservation 事件。 - 插入一条 assistant acknowledgement,让前端立即有反馈。
- 把 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]
这解决了两个问题:
- 旧产物可以保留,用于审计和上下文引用。
- 新追问可以只执行增量任务,不必从头跑完整流程。
连续追问下的并发控制
真实用户不会等系统慢慢执行完再提下一个问题。他可能连续发送:
- “把第三点展开。”
- “顺便加上风险。”
- “不要太长,按表格输出。”
如果没有并发控制,系统可能插入三批任务,互相覆盖状态,最终执行器看到重复 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,它才会成为真正的智能体能力。
追问应该被看成智能体架构里的第一等行为:用户不是在任务结束后又问了一句话,而是在把新的观察、新的约束、新的目标注入同一个认知过程。系统是否能接住这个变化,决定了它到底是一次性工具,还是可以持续协作的智能体。
参考文献与资料
- OpenAI, Introducing deep research, 2025-02-02;文中引用其关于多步研究、网页/文件/Python 工具、实时进度与追问细化的公开说明。
- OpenAI, Agents SDK documentation;用于参考 Agent definitions、orchestration、guardrails、results/state、observability、conversation state、background mode、streaming、multi-agent、computer use 等工程概念。
- Manus, Manus: Hands On AI;用于参考其“Less structure, more intelligence”产品定位,以及浏览器操作、Wide Research、Web app、AI slides、API 等工具型 Agent 形态。
- Shunyu Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, 2022.
- Noah Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning, 2023.
- Timo Schick et al., Toolformer: Language Models Can Teach Themselves to Use Tools, 2023.
- Joon Sung Park et al., Generative Agents: Interactive Simulacra of Human Behavior, 2023.
- Kimi Team, Kimi K2: Open Agentic Intelligence, 2025.
- Kimi Team, Kimi K2.5: Visual Agentic Intelligence, 2026.
评论
评论由 GitHub Issues 提供支持,需要登录 GitHub。