智能体工程与前端载体 (Agent Engineering & Frontend Harness)✅
本模块系统性梳理大模型智能体(Agent)在前端与全栈领域的全套工业级技能栈:Agent 四大核心要素、业界主流 Agent 框架与产品横向选型对比、扩展手段(Tools、MCP、Skills、Subagents)、前端 Agent Harness 宿主与思考链可视化、Human-in-the-Loop (HITL) 授权门禁、大型可视化 DAG 工作流编排画布,以及 Agent / Orchestrator / Harness / Runtime 四层运行时职责划分、多步工具轨迹的 Reward 设计,以及 Human-in-the-Loop 审批快照与执行绑定的一致性保证,并延伸到提示注入(直接注入与间接注入)的纵深防御架构,以及 Reflexion 语言化强化学习如何把失败经验沉淀为可复用的反思记忆。
什么是智能体(Agent)?Agent 的四大核心要素(LLM 大脑、规划 Planning、记忆 Memory、工具 Tools)是什么?
答案
核心结论:
在现代 AI 工程体系中,智能体(Autonomous Agent)是基于大语言模型作为核心控制器、具备感知环境、自主规划目标、调用工具行动并根据反馈持续迭代的自主计算系统。
传统的大模型交互是“一问一答的被动生成(Passive Completion)”,而 Agent 则是“自主达成目标的闭环行动(Goal-Driven Autonomous Action)”。现代 Agent 架构公认由**四大核心要素(Four Pillars)**构成:
- 大脑(Brain / LLM):负责全局语义理解、意图识别、复杂推理与下一步行动决断的核心大模型;
- 规划系统(Planning):将复杂总目标分解为可执行子任务链的能力(如 Sub-goal Decomposition、ReAct 思考-行动-观察循环、反思与自我纠错 Reflexion);
- 记忆系统(Memory):涵盖会话短期工作记忆(Short-term Working Memory)与外部向量库/知识图谱长期记忆(Long-term Episodic Memory);
- 工具体系(Tools / Actuators):Agent 与物理世界及业务系统交互的“手和脚”(如浏览器控制、文件读写、API 请求、代码执行沙箱)。
原理解析:
1. 智能体经典认知架构模型(Lilian Weng 经典拓扑)
规范代码实现:
在 TypeScript 中定义标准工业级 Agent 认知状态机构型:
// types/agent-architecture.ts
export interface AgentMemory {
shortTermHistory: Array<{ role: 'user' | 'assistant' | 'tool'; content: string }>;
workingScratchpad: string; // 当前推理草稿纸
queryLongTermMemory: (query: string) => Promise<string[]>;
}
export interface AgentTool {
name: string;
description: string;
parametersSchema: Record<string, unknown>; // JSON Schema
execute: (args: Record<string, unknown>) => Promise<unknown>;
}
export interface AgentAction {
toolName: string;
args: Record<string, unknown>;
thought: string;
}
export interface AgentStepResult {
thought: string;
action?: AgentAction;
observation?: unknown;
finalAnswer?: string;
isComplete: boolean;
}
面试官视角:
考察意图: 评估候选人是否理解从“Chat Completion”向“Agent”演进的范式转移。能够清晰背书 Agent 四大要素并指出每个要素在工程实现中的卡点(如 Planning 的死循环、Memory 的窗口超限、Tools 的执行超时),展现出成熟的系统性架构视野。
深度追问链:
- 追问 1: 在规划(Planning)过程中,如果大模型陷入了反复调用同一个失败工具的死循环,架构上如何防范?
- 答题切入点: 引入最大重试步数(
maxSteps阈值熔断)、循环调用指纹检测(Hash 检查连续 N 次调用的 Tool+Args 是否完全相同)、引入批评者(Critic / Evaluator)自我反思机制,强制模型重新规划路线。
- 答题切入点: 引入最大重试步数(
延伸阅读:
业界主流的 Agent 产品与框架有哪些(Cursor / Claude Code / AutoGen / LangGraph / CrewAI)?它们的架构选型与适用场景是什么?
答案
核心结论:
随着 Agent 技术的爆发,业界在**“编码助手垂直 Agent(Vertical Coding Agents)”与“通用应用编排框架(General Orchestration Frameworks)”**两个阵营涌现出代表性方案。前端与全栈架构师必须根据业务复杂度与控制力需求精准选型:
- 编码与生产力 Agent(端到端产品级):
- Cursor:深度结合 VS Code 客户端 Fork 与本地 LSP(Language Server Protocol),将代码库 AST 索引、本地向量检索与模型推理极致融合,擅长即时上下文补全与多文件原子编辑;
- Claude Code (Anthropic):以命令行终端(CLI / REPL)为载体、以系统原生工具(Bash、Read/Write、Grep)为原语的极简自主代理,采用无虚拟层的宿主系统直接交互,代表了“Bash is all you need”的强效能终端哲学;
- Agent 开发与编排框架(底层 SDK 级):
- LangGraph (LangChain 体系):基于**有向图(Graph)、循环状态机(StateGraph)与持久化检查点(Checkpointer)**设计,支持人类在环(HITL)与时光旅行,是目前构建复杂确定性生产 Agent 的事实标准;
- AutoGen (Microsoft):以**多智能体对话(Multi-Agent Conversation)与角色扮演(Role-Playing)**为核心哲学,擅长头脑风暴、代码生成与交叉评审;
- CrewAI:基于任务分工角色扮演的高层封装框架,上手极快,适合轻量级任务流自动化。
原理解析:
1. 主流 Agent 框架与产品核心架构选型全景矩阵
| 框架 / 产品 | 核心设计哲学 | 控制流模型 | 状态持久化机制 | 前端结合度与适用场景 |
|---|---|---|---|---|
| LangGraph | 图与状态机 (Cycles & Branches) | 显式有向图 (StateGraph),支持条件分支与并发 | 原生 Checkpointer 数据库,支持时光旅行 | 极高。前端可直接将 Graph 状态映射为可视化画布与分支状态机 |
| AutoGen | 多智能体对话总线 (Conversational) | 隐式对话传递 (GroupChat / RoundRobin) | 会话消息日志持久化 | 中。适合多模型会话互审,但复杂分支确定性控制力较弱 |
| CrewAI | 职能团队分工 (Role / Crew / Process) | 顺序式或层次式任务分派 (Sequential / Hierarchical) | 内存状态与简单 Hook | 中。快速搭建概念验证(PoC)与固定业务审批流 |
| Claude Code | 终端极简原生行动 (CLI / Bash Native) | Agentic Loop + 原生系统工具执行 | 会话上下文文件日志 | 极高 (研发效能)。前端工程资产化(Skills/Commands)与 CLI 自动化演进标杆 |
| Cursor | IDE 深度集成 (LSP + AST + Context) | 混合式 Agent (Tab 补全 + Composer 多文件) | 本地 SQLite 向量与文件快照 | 极高 (开发者体验)。前端业务代码实时编写与架构重构 |
2. 状态机图驱动 (LangGraph) vs 自由多智能体对话 (AutoGen)
规范代码实现:
在 TypeScript 中展示如何借鉴 LangGraph 的状态图思想,实现一个具备确定性状态流转与检查点回滚的极简图执行引擎:
// engine/state-graph.ts
export interface GraphState {
messages: Array<{ role: string; content: string }>;
currentStep: string;
retryCount: number;
isCompleted: boolean;
}
export type StateNodeHandler = (state: GraphState) => Promise<Partial<GraphState>>;
export class MinimalStateGraph {
private nodes: Map<string, StateNodeHandler> = new Map();
private edges: Map<string, (state: GraphState) => string> = new Map();
private entryPoint: string = '';
public addNode(name: string, handler: StateNodeHandler): this {
this.nodes.set(name, handler);
return this;
}
public addConditionalEdge(fromNode: string, router: (state: GraphState) => string): this {
this.edges.set(fromNode, router);
return this;
}
public setEntryPoint(nodeName: string): this {
this.entryPoint = nodeName;
return this;
}
public async run(initialState: GraphState): Promise<GraphState> {
let state = { ...initialState };
let currentNode = this.entryPoint;
while (currentNode && !state.isCompleted) {
console.log(`[StateGraph] 执行节点: ${currentNode}`);
const handler = this.nodes.get(currentNode);
if (!handler) break;
const patch = await handler(state);
state = { ...state, ...patch, currentStep: currentNode };
const router = this.edges.get(currentNode);
if (!router) break;
currentNode = router(state);
}
return state;
}
}
面试官视角:
考察意图: 评估应试者是否真正沉浸在当前大模型前沿技术生态中。初级应聘者仅知道“调用 ChatGPT 写个 prompt”,而高级架构师能够清晰对比不同 Agent 框架的设计哲学(图状态机 vs 多角色对话),并能为企业选择最合适的技术路线。
深度追问链:
- 追问 1: 为什么在严肃企业生产场景(如银行审批、工单流转)中,工业界更推崇 LangGraph 这种图状态机,而不是让多个 Agent 自由自由对话的 AutoGen?
- 答题切入点: 自由多智能体对话存在“不可控的死循环、话题漂移、执行路径不可预测”的严重风险;而 LangGraph 提供了明确的状态转换约束、检查点持久化、确定性回滚以及严格的条件判断,符合企业软件高可靠、可审计的合规要求。
延伸阅读:
Agent 主要的扩展手段有哪些?除了 Function Calling / Tool Use,MCP 协议、Skills 机制与 Subagent 多智能体协作是如何扩展 Agent 能力的?
答案
核心结论:
大语言模型的能力边界受限于其预训练权重中的静态参数知识。为了让 Agent 能够操作企业内部系统、调用私有 API、甚至自我进化,业界发展出了四大层次分明的Agent 扩展手段(Extension Mechanisms):
- 第一代基石扩展:Function Calling / Tool Use(单点工具调用):
- 开发者在请求中提供工具的 JSON Schema,模型根据意图决定调用哪个函数并返回结构化参数;本质是点对点的硬编码接入,新增工具必须修改客户端代码;
- 协议化生态扩展:Model Context Protocol (MCP,模型上下文协议):
- Anthropic 推出的开放标准。将工具(Tools)、数据源(Resources)和提示词模板(Prompts)抽象为跨语言、跨进程的标准化 Client-Server 协议,实现工具解耦与即插即用(USB-C 标准);
- 经验与规约沉淀:Skills 机制(技能化演进):
- 将专业领域工作流规范、排查步骤、前置依赖与最佳实践以结构化文档或脚本集合封装(如
SKILL.md+ 辅助脚本);Agent 按需加载技能,避免将所有 Prompt 塞入上下文导致注意力崩溃;
- 将专业领域工作流规范、排查步骤、前置依赖与最佳实践以结构化文档或脚本集合封装(如
- 横向能力倍增:Subagents 多智能体编排协作(Multi-Agent System):
- 主智能体(Parent Agent)作为指挥官,将复杂任务拆解并派发给专注垂直领域的子智能体(如 Research Agent、Code Review Agent),子智能体独立运行并只汇报最终结论,实现上下文窗口物理隔离与算力并发。
原理解析:
1. 四大 Agent 扩展机制演进对比矩阵
| 扩展机制 | 耦合度 | 上下文消耗 | 动态扩展能力 | 核心适用场景 |
|---|---|---|---|---|
| Function Calling | 强耦合(客户端硬编码工具定义) | 随工具数量线性暴增 | 差(需发版修改调用代码) | 单一系统内部 2~5 个高频核心业务 API |
| MCP 协议 | 完全解耦(标准化 Client-Server RPC) | 支持动态发现与按需挂载 | 极高(配置 JSON 即可接入第三方工具) | 跨部门系统互联、IDE 与桌面端工具打通 |
| Skills 机制 | 低耦合(轻量文本元数据索引) | 渐进式披露(按需检索加载正文) | 高(只需添加规范 Markdown/脚本) | 沉淀工程经验、特定库最佳实践、复杂排错 SOP |
| Subagents 编排 | 完全隔离(独立会话上下文) | 零污染(子 Agent 上下文独立闭环) | 极高(可并发分发、递归派生) | 复杂大型工程战役、跨模块并行重构、深度调研 |
2. 四大扩展机制协同工作全景拓扑
规范代码实现:
在 TypeScript 中实现支持 Skills 按需加载与 Subagent 上下文隔离派发的模块化 Agent 扩展调度器:
// core/agent-extension-dispatcher.ts
export interface AgentSkill {
name: string;
description: string;
loadInstruction: () => Promise<string>;
}
export interface SubagentConfig {
role: string;
prompt: string;
tools: string[];
}
export class AgentExtensionDispatcher {
private skills: Map<string, AgentSkill> = new Map();
public registerSkill(skill: AgentSkill): void {
this.skills.set(skill.name, skill);
}
// 1. 技能渐进式披露:主 Agent 仅需在 Prompt 里注入技能清单与一句话描述
public getSkillsIndexPrompt(): string {
const list = Array.from(this.skills.values())
.map(s => `- ${s.name}: ${s.description}`)
.join('\n');
return `当前可用专业技能库 (根据任务需要可申请加载具体技能):\n${list}`;
}
// 2. 派发隔离的子智能体执行 (Subagent Context Isolation)
public async dispatchSubagent(config: SubagentConfig): Promise<string> {
console.log(`[Subagent] 派发独立工作单元: ${config.role}`);
// 独立实例化一个没有历史杂质的新会话上下文
const isolatedMessages = [
{ role: 'system', content: `你是 ${config.role}。独立专注完成以下任务,只输出最终结论。` },
{ role: 'user', content: config.prompt }
];
// 执行完成后销毁中间状态,只将最终结果作为 Observation 汇入主 Agent
const result = await fakeExecuteLlm(isolatedMessages);
return `[Subagent: ${config.role} 报告]: ${result}`;
}
}
async function fakeExecuteLlm(messages: any[]): Promise<string> {
return '子任务执行完毕,无安全隐患。';
}
面试官视角:
考察意图: 评估候选人对构建复杂 Agent 系统的扩展能力与演化路径认知。只有做过真实复杂 Agent 的工程师,才深刻明白把几十个工具硬编码进 System Prompt 会导致注意力彻底崩溃,必须依靠“MCP 协议标准接入 + Skills 渐进披露 + Subagent 物理隔离”来实现大规模工业落地。
深度追问链:
- 追问 1: 为什么在长周期开发任务中,单 Agent 往往越做越慢甚至胡言乱语,而采用 Subagent 派发后成功率大幅提高?
- 答题切入点: 单 Agent 随着任务推进,历史日志中充斥着海量的中间输出与工具错误,上下文被大量噪声污染(Context Rotting);Subagent 独立开辟干净的上下文执行专注子任务,完成后只回传高密度的结构化总结,彻底保护了主 Agent 的注意力黄金带宽。
延伸阅读:
前端如何架构大模型 Agent 交互界面(Agent Harness)?ReAct 规划决策轨迹与思考链流式可视化?
答案
核心结论:
在现代 AI 应用中,前端不再是一个简单的聊天窗口,而是智能体(Agent)运行与人机协作的物理宿主环境(Agent Harness)。
前端承接 Agent 交互必须解决三大核心诉求:
- ReAct 认知生命周期状态机(Reasoning and Acting State Machine):精确建模并流式响应 Agent 的思考(Thought)、行动(Action/Tool Call)、观察(Observation/Tool Result)与终态输出(Answer);
- 思考链(Chain-of-Thought)与工具调用的防抖排版与折叠:区分“面向机器的中间思考/工具日志”与“面向用户的最终交互内容”;默认将推理链与工具调用折叠为紧凑卡片,支持用户展开查看细节;
- 全双工事件驱动总线:通过标准化协议事件(如
thought_delta、tool_call_start、tool_result)驱动视图细粒度增量更新,杜绝全屏刷新。
原理解析:
1. ReAct 决策循环与前端视图状态流转
规范代码实现:
在 TypeScript/React 中实现标准 ReAct 轨迹时间轴组件,支持流式思考与工具结果插槽展示:
// components/agent-trajectory-viewer.tsx
import React, { FC, useState } from 'react';
export interface TrajectoryStep {
id: string;
thought?: string;
toolCall?: {
name: string;
args: Record<string, unknown>;
status: 'running' | 'success' | 'failed';
result?: unknown;
};
answer?: string;
}
export const AgentTrajectoryViewer: FC<{ steps: TrajectoryStep[] }> = ({ steps }) => {
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
const toggleExpand = (id: string) => {
setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
};
return (
<div className="agent-harness-timeline space-y-4 p-4">
{steps.map((step, idx) => (
<div key={step.id} className="border-l-2 border-indigo-500 pl-4 space-y-2">
{/* 1. 思考链展示 */}
{step.thought && (
<div className="text-sm text-gray-500 italic bg-gray-50 p-2 rounded">
<span className="font-semibold text-indigo-600 mr-2">💭 思考 #{idx + 1}:</span>
{step.thought}
</div>
)}
{/* 2. 工具调用轨迹与状态徽章 */}
{step.toolCall && (
<div className="tool-call-box p-3 bg-white border rounded shadow-sm">
<div className="flex items-center justify-between cursor-pointer" onClick={() => toggleExpand(step.id)}>
<span className="font-mono text-sm font-bold text-gray-800">
🛠️ 工具调用: {step.toolCall.name}
</span>
<span className={`text-xs px-2 py-1 rounded font-bold ${
step.toolCall.status === 'running' ? 'bg-blue-100 text-blue-700 animate-pulse' :
step.toolCall.status === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{step.toolCall.status}
</span>
</div>
{expandedIds[step.id] && (
<div className="mt-2 space-y-2 text-xs font-mono">
<pre className="bg-gray-100 p-2 rounded overflow-x-auto">
参数: {JSON.stringify(step.toolCall.args, null, 2)}
</pre>
{step.toolCall.result && (
<pre className="bg-emerald-50 p-2 rounded text-emerald-800 overflow-x-auto">
返回值: {JSON.stringify(step.toolCall.result, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
{/* 3. 最终答案输出 */}
{step.answer && (
<div className="final-answer text-gray-900 bg-emerald-50 p-4 border border-emerald-200 rounded">
{step.answer}
</div>
)}
</div>
))}
</div>
);
};
面试官视角:
考察意图: 评估候选人设计复杂人机协作界面的架构功底。许多开发者停留在“聊天对话框”级别,缺乏处理长时间运行(几十步工具调用)、中间执行失败重试、中间过程排版折叠的工业级 Agent 前端架构能力。
深度追问链:
- 追问 1: 在 Agent 执行了 20 步工具调用、生成了极其冗长的 DOM 节点时,前端如何保证页面滚动和性能不卡顿?
- 答题切入点: 采用虚拟列表(Virtual List / React Window)对历史步骤按窗口渲染;已完成的历史步骤仅保留折叠概要,展开时动态加载;将工具调用的高频 JSON 解码与状态计算放入 Web Worker。
延伸阅读:
Agent 执行高风险工具(如删除文件、对外转账)时,前端如何设计 Human-in-the-Loop (HITL) 动态授权拦截门禁?
答案
核心结论:
在智能体拥有执行真实操作权限的时代,人类在环(Human-in-the-Loop, HITL)是防止大模型发生灾难性破坏、确保企业资产安全的生死底线。
当 Agent 意图执行可能造成不可逆破坏的危险工具(如 delete_database_records、execute_shell_rm、transfer_funds)时,前端与运行时架构必须实施异步授权拦截门禁(Asynchronous Action Interceptor):
- 工具风险等级分级矩阵(Risk Tiering):分为只读级(Safe Read,免审批自动执行)、普通写(Low-Risk Write,被动记录日志)与高危破坏级(High-Risk Mutating,强制挂起审批);
- 基于 Promise 控制反转的状态机挂起(Suspension via Inverted Promise):Agent 循环在触发高危工具时立即暂停推理,向前端派发
awaiting_approval事件并呈现实时差异对比(Diff)与操作上下文; - 超时保护与上下文隔离:若用户超过限定时间未批准,默认触发超时拒绝(Safe Default: Reject on Timeout),防止模型上下文无限期挂起引发连接泄漏。
原理解析:
1. HITL 动态授权时序与控制反转流
规范代码实现:
在 TypeScript 中实现可取消、带超时熔断的通用 Human-in-the-Loop 异步安全拦截器:
// security/hitl-interceptor.ts
export interface DangerousActionRequest {
id: string;
toolName: string;
parameters: Record<string, unknown>;
dangerLevel: 'CRITICAL' | 'HIGH';
description: string;
}
export type ApprovalDecision =
| { approved: true; modifiedParams?: Record<string, unknown> }
| { approved: false; reason: string };
export class HitlActionInterceptor {
private pendingResolvers: Map<string, (decision: ApprovalDecision) => void> = new Map();
private readonly defaultTimeoutMs = 120000; // 2 分钟未响应自动拒绝
// 1. Agent 引擎调用此方法:若为高危操作则异步挂起,等待前端用户点击
public async interceptAction(request: DangerousActionRequest): Promise<ApprovalDecision> {
return new Promise<ApprovalDecision>((resolve) => {
// 超时安全兜底:防止悬挂
const timer = setTimeout(() => {
if (this.pendingResolvers.has(request.id)) {
this.pendingResolvers.delete(request.id);
resolve({ approved: false, reason: '用户审批超时,系统已自动拦截操作' });
}
}, this.defaultTimeoutMs);
this.pendingResolvers.set(request.id, (decision) => {
clearTimeout(timer);
resolve(decision);
});
// 触发前端 UI 弹窗挂起
window.dispatchEvent(new CustomEvent('ai:hitl_requested', { detail: request }));
});
}
// 2. 前端弹窗组件中的“同意/拒绝”按钮回调此方法
public submitDecision(requestId: string, decision: ApprovalDecision): void {
const resolver = this.pendingResolvers.get(requestId);
if (resolver) {
resolver(decision);
this.pendingResolvers.delete(requestId);
}
}
}
面试官视角:
考察意图: 评估候选人对企业级安全生产与权限控制的敬畏之心。让大模型直接在生产环境“跑命令、改数据”是极不负责的架构设计;懂得使用 HITL 门禁、风险分级、超时自动熔断的工程师才具备主导企业核心系统的资质。
深度追问链:
- 追问 1: 用户点击“拒绝”后,如何把拒绝理由传回给大模型,让大模型不报错并懂得“换一种方式实现”?
- 答题切入点: 将拦截视为一次特殊的工具执行结果(Tool Result),内容为“操作已被操作员拒绝,理由是:权限不足,请采用只读方案重新规划”,大模型读取此 Observation 后会自动进入下一次 ReAct 思考,实现业务优雅自愈。
延伸阅读:
如何设计类似 Dify / Coze 的可视化 DAG 工作流编排画布?拓扑排序与流式执行调试如何落地?
答案
核心结论:
在复杂大模型与自主智能体(Agent)产品中,基于有向无环图(DAG, Directed Acyclic Graph)的可视化工作流编排画布(如 Dify、Coze、LangFlow)已成为取代单一聊天框的战略级交互形态:
- 混合渲染架构:千万级坐标系与视口平移缩放(Pan & Zoom)下,纯 DOM/React 组件会导致严重重绘重排;工业界采用**“底层 Canvas / SVG 绘制连线与网格背景 + 顶层 HTML/DOM 挂载交互节点(Node UI)”的混合分层架构**,兼顾渲染性能与表单交互能力;
- 确定性执行调度(Topological Scheduling):编排数据结构本质是邻接表(Adjacency List);执行前必须通过 Kahn 算法或深度优先搜索(DFS)进行环路检测与拓扑排序,确保上游依赖节点先于下游节点执行;
- 分步流式观察与断点调试:每个工作流节点作为独立的有限状态机(
idle | running | streaming | success | failed),支持流式 Token 实时打字输出与局部单步重试(In-place Retry)。
原理解析:
1. DAG 工作流节点依赖拓扑与执行生命周期
规范代码实现:
在 TypeScript 中实现工业级 DAG 拓扑排序算法与依赖环路检测引擎:
// engine/dag-scheduler.ts
export interface WorkflowNode {
id: string;
name: string;
execute: (inputs: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
export interface WorkflowEdge {
from: string; // 上游节点 ID
to: string; // 下游节点 ID
}
export class DagWorkflowEngine {
// Kahn 算法拓扑排序与有向环检测
public static getExecutionPlan(nodes: WorkflowNode[], edges: WorkflowEdge[]): string[][] {
const inDegree = new Map<string, number>();
const graph = new Map<string, string[]>();
nodes.forEach(n => {
inDegree.set(n.id, 0);
graph.set(n.id, []);
});
edges.forEach(e => {
inDegree.set(e.to, (inDegree.get(e.to) || 0) + 1);
graph.get(e.from)!.push(e.to);
});
const executionStages: string[][] = [];
let readyNodes = nodes.filter(n => inDegree.get(n.id) === 0).map(n => n.id);
let visitedCount = 0;
while (readyNodes.length > 0) {
executionStages.push([...readyNodes]);
visitedCount += readyNodes.length;
const nextBatch: string[] = [];
for (const nodeId of readyNodes) {
for (const neighbor of graph.get(nodeId) || []) {
const currentDeg = inDegree.get(neighbor)! - 1;
inDegree.set(neighbor, currentDeg);
if (currentDeg === 0) {
nextBatch.push(neighbor);
}
}
}
readyNodes = nextBatch;
}
if (visitedCount !== nodes.length) {
throw new Error('[DAG-Scheduler] 流程图存在循环依赖环路(Cycle detected),无法执行!');
}
return executionStages;
}
}
面试官视角:
考察意图: 评估应试者是否具备大型图形学、复合数据流引擎与前端高性能可视化的跨界实战力。在大模型时代,AI 应用工程师如果不仅会做 Chat,还能独立架构 DAG 工作流画布与调度器,是极具溢价的核心竞争力。
深度追问链:
- 追问 1: 在节点并发合流场景(如两个平行分支同时输出结果输入给下一个节点),状态如何在前端实时汇聚并保证流式渲染?
- 答题切入点: 采用集中式状态总线(Node Output Bus),下游节点通过响应式监听器等待所有前置依赖的
status === 'success',当所有上游就绪后自动启动触发,数据通过输入映射管道精准注入。
- 答题切入点: 采用集中式状态总线(Node Output Bus),下游节点通过响应式监听器等待所有前置依赖的
延伸阅读:
Agent = Model + Harness 这个等式怎么理解?Harness 到底决定了 Agent 的什么?
答案
核心结论:
在企业落地语境里,Agent 的实际能力并不由模型单独决定,而是由 Model(模型智力)× Harness(运行载体工程) 共同决定。一个可用的企业级 Harness 承担六件事:
- 上下文装配(Context Assembly):决定每一轮把什么放进窗口、什么外置、什么按需检索;
- 工具接入与权限(Tool & Permission):工具注册、参数校验、风险分级与人工放行;
- 执行循环与停止条件(Loop & Termination):步数预算、无进展检测、循环指纹熔断;
- 状态与记忆持久化(State & Memory):断点续跑、会话摘要、长期记忆的读写边界;
- 可观测与干预(Observability & Intervention):全链路 Trace、失败归因、人工接管与回滚;
- 评测与回归(Eval & Replay):把线上真实失败沉淀为可重放的回归用例。
工程杠杆的关键判断:换模型是把「天花板」上移,改 Harness 是把「已有模型的利用率」从 40% 抬到 85%。 在模型能力短期不可控时,后者的收益往往更大、也更可落地。
原理解析:
1. 为什么同一个模型在不同 Agent 产品里表现差出两倍
差异几乎全部来自 Harness,而不是模型。模型决定「单步推理好不好」,Harness 决定「这单步推理放在什么上下文里、拿到什么工具、错了能不能被发现、能不能重试」。
2. 能力归属对照表
| 能力项 | 属于 Model | 属于 Harness | 说明 |
|---|---|---|---|
| 语义理解与推理质量 | ✅ | — | 换模型直接改变 |
| 工具选择准确率 | 部分 | ✅ | 工具描述质量、工具数量、命名空间隔离都由 Harness 决定 |
| 复杂任务完成率 | 部分 | ✅ | 步数预算、失败重规划、上下文新鲜度是 Harness 变量 |
| 稳定性与可控性 | — | ✅ | 循环检测、熔断、降级、人工门禁全在 Harness |
| 成本与时延 | 部分 | ✅ | 上下文裁剪、缓存前缀对齐、模型分级路由由 Harness 决定 |
| 可审计与合规 | — | ✅ | 工具调用全量留痕、权限分级、人工审批点在 Harness |
3. 常见误判
- 误判一:把 Harness 当模型的一部分。结果是把「上下文没装好导致模型选错工具」当成「模型不够强」去换模型,成本花光、问题还在。
- 误判二:只堆工具不治理工具。工具从 5 个涨到 80 个后,模型选择准确率反而下降——因为工具表本身占上下文,且互相语义重叠。
- 误判三:没有 Trace 就没有 Harness。没有全链路轨迹,任何「效果变差」都只能靠猜。
规范实现:
一个可插拔的最小 Harness 骨架(TypeScript),把六个能力收敛成可替换的策略接口:
// harness/types.ts
export interface HarnessPolicy {
assembleContext(input: TurnInput): Promise<ContextBlock[]>;
dispatch(toolCall: ToolCall): Promise<ToolOutcome>;
shouldStop(state: RunState): StopDecision;
onError(state: RunState, err: unknown): RecoveryAction;
}
export type StopDecision =
| { kind: 'done' }
| { kind: 'budget-exhausted'; spent: number; limit: number }
| { kind: 'no-progress'; fingerprint: string };
export type RecoveryAction =
| { kind: 'retry'; backoffMs: number }
| { kind: 'replan'; hint: string }
| { kind: 'escalate'; to: 'human' | 'operator' };
export interface RunState {
steps: number;
toolCallFingerprints: string[]; // 连续相同 Tool+Args 检测死循环
lastMeaningfulChangeAt: number; // 无进展检测
budget: { steps: number; tokens: number };
}
工程上真正决定效果的是三处策略选择:上下文装配顺序(把最关键的约束放在离生成最近的位置)、停止条件(步数预算 + 指纹去重 + 无进展检测三者缺一不可)、错误分级(可重试 / 需重规划 / 需人工,三类走不同路径,混在一起重试是线上雪崩的常见根因)。
面试官视角:
考察意图: 本考点在 2026 年的 Agent 岗面试中已是高频「大局观」题,回答质量直接区分「用过 Agent 工具」与「搭过 Agent 系统」。评分重点不在于能否背出 Model/Harness 术语,而在于能否说清 哪些能力属于 Harness、如何定位一次失败到底怪模型还是怪 Harness。
深度追问链:
- 追问 1: 线上发现 Agent 成功率下降,你怎么判断是模型换代的锅还是 Harness 的锅?
- 答题切入点: 先看分层指标——工具选择准确率(纯 Harness 变量,模型换代一般不降)、单步推理正确率(模型变量)、步数分布(上下文/Harness 变量)。用同一批 Golden Case 在新旧模型上跑 A/B,工具选择率不变而单步正确率下降则指向模型;反之指向 Harness。
- 追问 2: Harness 工具从 20 个扩到 100 个后效果反而变差,你怎么排查?
- 答题切入点: 工具表本身占用上下文且语义互相污染——按领域做命名空间隔离、只把当前子域的工具注入、把工具描述当 Prompt 资产做版本管理与回归。
- 追问 3: 让你把公司现有系统改造成 Agent 形态,Harness 层你打算先做哪三件事?
- 答题切入点: 先做可观测(Trace 全链路),再做停止条件与失败分级,最后做上下文装配策略;直接上自主规划是风险最高的选择。
延伸阅读:
什么场景该做成一个专职 Agent,什么场景只需要一个 Skill?两者对上下文窗口的占用差多少?
答案
核心结论:
判据不是「任务看起来复杂不复杂」,而是三条硬标准:
- 是否需要独立的目标(Goal):Skill 没有自己的目标,只执行被触发的既定步骤;Agent 有自己的目标与任务边界。
- 是否需要独立的状态与终止条件:Skill 的执行流由宿主控制、步数固定;Agent 自己决定下一步、并自己判断何时结束。
- 是否需要根据现场信息改变步骤:步骤可枚举、可预编排的,用 Skill;分支依赖运行时观测结果的,才用 Agent。
上下文成本差着量级:Skill 以「摘要 + 工具契约」的形式常驻(通常几十到数百 Token),Agent 需要携带完整轨迹(起步几千、复杂任务可达数万 Token),并且随步数线性膨胀。
演进路径的正确顺序是先 Skill、后 Agent:先按 Skill 做,观测到「步骤必须依现场调整」再升级为 Agent;反之把固定流程做成 Agent,是把可预测性白白换成不确定性和审计成本。
原理解析:
1. 决策判据
| 判据 | 用 Skill | 用 Agent |
|---|---|---|
| 步骤序列 | 固定、可枚举 | 运行时才确定 |
| 终止条件 | 宿主已知(跑完 N 步) | Agent 自行判定(完成/放弃/超预算) |
| 失败处理 | 固定重试或直接报错 | 需要重规划、换工具、拆子任务 |
| 上下文占用 | 摘要常驻,Token 量级 10² | 全轨迹携带,Token 量级 10³~10⁴ |
| 结果确定性 | 高(可回归) | 低(需 Eval 守护) |
| 权限面 | 小,工具白名单固定 | 大,动态触达的工具面更宽 |
2. 上下文占用的量级差
以一个典型研发助手为例:
结论很直接:Agent 的上下文不是「更多知识」,而是「更多过程」,过程恰恰是最容易挤爆预算、也最容易稀释关键约束的部分。这也是长任务里模型表现衰减的主因之一。
3. 两个反模式
- 过度自主(Over-Autonomy):把「拉取仓库 → 跑测试 → 提 PR」这种完全确定的流程做成 Agent。代价是每次结果不同、失败原因难归因、且每次都要烧掉大量 Token。
- 能力不足(Under-Autonomy):把「根据报错定位并选择合适的修复策略」这种本质需要判断的环节写成 Skill。结果是 Skill 覆盖不全,宿主被迫把所有分支都塞进提示词,Skill 反而比 Agent 更臃肿。
规范实现:
用可判定的条件把「选 Skill 还是选 Agent」编码成团队约定,避免每次靠直觉:
// skill-vs-agent.ts
export type Granularity = 'skill' | 'agent';
export interface TaskShape {
stepsAreDeterministic: boolean; // 步骤是否可枚举且顺序固定
branchDependsOnRuntimeObservation: boolean; // 分支是否依赖运行时观测
needsOwnGoal: boolean; // 是否有独立目标与完成定义
needsOwnTermination: boolean; // 是否需要自主终止判定
steps: number;
expectedFailureModes: number; // 失败模式是否可枚举
}
export function chooseGranularity(t: TaskShape): Granularity {
const deterministic = t.stepsAreDeterministic && !t.branchDependsOnRuntimeObservation;
const enumerableFailures = t.expectedFailureModes <= 2;
// 步骤确定 + 失败可枚举 => 编排为 Skill;否则升级为 Agent
return deterministic && enumerableFailures && !t.needsOwnTermination ? 'skill' : 'agent';
}
配套的团队规约(写进 SDD 规则库,而不是靠口头约定):
- 同一能力先落 Skill,连续两次出现「因为现场情况不同导致 Skill 走不通」才升级为 Agent;
- 升级时必须同时补齐:停止条件、失败分级、Trace 埋点、Eval 用例;
- Agent 只在收益可度量时引入——同一任务成功率提升不足以覆盖 Token 与时延成本,就退回 Skill。
面试官视角:
考察意图: 这是 2026 年 Agent 应用岗的经典分水岭题:候选人是否理解「自主性是一种要被主动约束的成本,而不是能力越高越好的指标」。高分回答必须同时给出判据、上下文成本量级和演进顺序三层。
深度追问链:
- 追问 1: 你们的 Skill 和 Agent 现在分别有多少个?有没有统计过各自的 Token 成本与成功率?
- 答题切入点: 给出以「调用量 × 平均上下文长度 × 步数」为口径的成本台账;实践中 Skill 单位调用成本通常低一个数量级,但覆盖率不足会导致宿主堆提示词,隐性成本上升。
- 追问 2: MCP 和 Skill 都能扩展能力,两者对上下文窗口的占用谁更大?
- 答题切入点: Skill 需要把执行步骤与约束注入上下文,占用随 Skill 复杂度线性增长;MCP 侧工具是「声明 + 按需发现 + 按需调用」,声明部分常驻、结果按需注入,通常更省。但若一次性把几十个工具的完整 schema 塞进上下文,MCP 反而更贵——真正贵的是工具表的语义冗余,不是协议本身。
- 追问 3: 什么时候你会把一个已经上线的 Agent 降级回 Skill?
- 答题切入点: 步骤方差收敛、失败模式可枚举、成功率瓶颈已不在自主性而在工具可靠性,且 Token 成本与 P99 时延不达标时降级。
延伸阅读:
Agent 该完全自主决策,还是用固定 Workflow 编排?出现什么信号说明该切换?
答案
核心结论:
自主性不是越高越好。真正决定选型的是三个量:可预测性要求、失败半径、审计成本。
工业界可落地的做法是三档自主度,并按任务逐个定档:
- 确定性编排(Workflow):步骤与分支完全预定义,模型只负责节点内的局部生成(写文案、抽字段、生成 SQL)。适合高频、可枚举、错误可容忍的任务。
- 受控自主(ReAct + 关键节点约束):模型自己决定下一步,但步数预算、工具白名单、关键节点人工门禁由编排器强制。适合步骤分叉多、但失败可回滚的任务。
- 完全自主(仅限离线探索与评测场景):不承载生产承诺,允许长时探索。
可观测的切换信号(不要凭感觉):
- 步骤方差大、同一意图反复走出不同路径 → 升自主;
- 重复任务的步骤方差收敛、失败可枚举 → 降自主(编排化更便宜也更稳);
- 成功率随步数呈指数衰减,或异常分支占比高 → 升自主前先补失败分级,不要直接放开。
工程落点是「骨架固定、血肉生成」:把关键决策点从模型手里收回到编排器手里,模型只负责节点内的生成与局部判断。
原理解析:
1. 自主度分级与失败半径
| 自主度 | 决策权归属 | 失败半径 | 审计成本 | 适用任务 |
|---|---|---|---|---|
| 确定性编排 | 编排器 | 单节点,可重放 | 低(固定路径) | 表单抽取、分类、格式化、批量改写 |
| 受控自主 | 模型 + 编排器约束 | 步数级,可熔断 | 中(Trace 完整) | 排障定位、代码修改、跨系统取数 |
| 完全自主 | 模型 | 全局,难回滚 | 高 | 离线研究、评测探针、批量实验 |
2. 混合编排的拓扑
3. 失败半径决定权限,而非模型强弱
同一个模型在「生成一段文案」和「改线上代码」上的风险完全不同。选型的第一原则是:能回滚的放开,不可回滚的收紧。高风险节点(删除、转账、发消息、改权限)必须落到编排器手里做硬约束或人工确认,不能只靠模型自觉。
规范实现:
把「骨架固定、血肉生成」落成可执行的编排器骨架:
// orchestrator.ts
export interface OrchestrateOptions {
goal: string;
maxSteps: number; // 受限 Agent 段的步数预算
toolAllowlist: string[]; // 工具白名单
requireApproval: (tool: string, args: unknown) => boolean; // 硬门禁
onPartial: (result: PartialResult) => void; // 降级出口
}
export async function orchestrate(node: WorkflowNode, opts: OrchestrateOptions) {
const state = { steps: 0, spent: 0 };
for (const step of node.steps) {
if (state.steps >= opts.maxSteps) {
// 超预算:返回部分结果并显式标注缺口,而不是静默截断
opts.onPartial({ done: false, reason: 'budget-exhausted', state });
return { done: false };
}
const call = await step.plan(state);
if (opts.requireApproval(call.tool, call.args)) {
const ok = await humanGate(call); // 人工确认点
if (!ok) { opts.onPartial({ done: false, reason: 'rejected' }); return { done: false }; }
}
const out = await step.execute(call, state);
state.steps += 1;
if (out.stop) return { done: true, result: out.result };
}
return { done: true };
}
编排器与模型的职责边界要写死在代码结构里,而不是写在提示词里:模型只产出「动作意图」,动作是否合法由编排器判定。这样即使模型被提示注入诱导,权限边界仍在代码层。
面试官视角:
考察意图: 考察候选人是否具备「把不确定性关进笼子里」的工程判断力。高分回答必须能给出可观测的切换信号和失败半径两条硬依据,而不是泛泛地讲「看场景」。
深度追问链:
- 追问 1: 你的项目里 Agent 说要自主决策,实际却用了固定 Workflow 编排,这个矛盾怎么解释?
- 答题切入点: 承认这是刻意的设计取舍——自主性用在「步骤分叉不可枚举」的节点,固定编排用在「可枚举且高频」的节点;同时给出当时的判据(失败可枚举性、失败半径)与观测数据(编排化后成功率与成本的变化)。
- 追问 2: 怎么让模型在编排里不越权?
- 答题切入点: 权限判定放在编排器代码层而非提示词;工具白名单 + 参数 schema 校验 + 风险分级 + 人工门禁;模型输出只作为「意图」,由宿主校验后执行。
- 追问 3: 受限 Agent 段应该给多少步数预算?
- 答题切入点: 由「单步成功率」推算——若单步成功率为 p,则 n 步全对概率约 p的n 次方;据此反推在目标成功率(如 0.8)下允许的最大步数,并配合无进展检测提前熔断,而不是拍一个固定值。
延伸阅读:
多个 Agent 并发协作时,如何处理调度冲突、输出冲突和子 Agent 失败?
答案
核心结论:
三类冲突必须用三套不同机制解决,混为一谈是线上事故的主要来源:
- 调度冲突(多个 Agent 同时争用同一资源/文件) → 互斥与租约。优先用乐观并发(版本号 / ETag / 文件哈希 + 冲突后重算),只有当操作不可重放或代价极高时才上悲观锁(分布式锁 + 租约超时 + 续期)。
- 输出冲突(多个 Agent 对同一结论给出不同答案) → 证据优先 + 仲裁。每个结论必须携带可回溯的证据引用(文件路径 + 行号、接口返回、检索片段),仲裁者在证据上判优,绝不能「最后写入者胜」或「多数票」了事。
- 失败传播(子 Agent 挂掉 / 超时 / 返回空) → 超时 + 熔断 + 降级。部分结果可用的场景返回部分结果并显式标注缺口,让上游知道哪些结论没有证据支撑。
分布式锁不是万能解:锁粒度过大就退化成串行,租约过期会脑裂,时钟漂移会误判。「能用版本号解决就不要用锁」是这条考点最想听到的判断。
原理解析:
1. 三类冲突的判定与处理
2. 乐观并发 vs 悲观锁
| 维度 | 乐观并发(版本号) | 悲观锁(分布式锁) |
|---|---|---|
| 冲突假设 | 冲突少,撞上再重算 | 冲突多,需串行化 |
| 吞吐 | 高,无锁开销 | 低,锁成为串行点 |
| 失败模式 | 重试次数失控 | 租约过期脑裂、锁泄漏 |
| 适合 | 文本/代码编辑、配置合并 | 支付扣减、库存扣减、独占资源 |
| 关键配套 | 重试上限 + 指数退避 | 唯一持有者确认 + 续期 + 兜底释放 |
3. 输出冲突为什么必须以证据为准
多数投票在 Agent 场景下有个致命前提:多个 Agent 共享同一份上下文和同一类错误倾向时,它们会一起错——多数票会稳定地收敛到错误答案。所以仲裁的依据必须是彼此独立的证据(不同数据源、不同的检索路径、确定性校验如类型检查/单测/Schema 校验),而不是「几个 Agent 都这么说」。
4. 失败传播的常见错误
- 用同一个重试策略对待所有失败:可重试(网络抖动)与不可重试(参数非法)混在一起,重试风暴会拖垮整个 Agent 集群。
- 失败后静默返回空结果:上游无法区分「确实没有」和「执行失败了」,错误会在链路里被放大。
规范实现:
以「多个 Agent 并发改同一份代码」为最小场景,实现乐观并发 + 证据仲裁 + 失败降级:
// multi-agent-coordination.ts
export interface AgentClaim {
agentId: string;
conclusion: string;
evidence: Array<{ uri: string; range?: [number, number]; excerpt: string }>;
}
export interface Verdict {
winner: AgentClaim | null;
reason: string;
gapNotice?: string; // 降级时显式标注的证据缺口
}
export async function arbitrate(claims: AgentClaim[], verify: (c: AgentClaim) => Promise<boolean>): Promise<Verdict> {
// 1) 过滤掉没有证据的结论:无证据不得参与仲裁
const withEvidence = claims.filter(c => c.evidence.length > 0);
if (withEvidence.length === 0) {
return { winner: null, reason: 'no-evidence', gapNotice: '所有候选结论均缺少可回溯证据,需重跑并补齐引用' };
}
// 2) 确定性校验优先:能用规则判定的,不要交给模型判
const verified: AgentClaim[] = [];
for (const c of withEvidence) {
if (await verify(c)) verified.push(c);
}
if (verified.length === 1) return { winner: verified[0], reason: 'only-verified-candidate' };
if (verified.length === 0) {
return { winner: null, reason: 'verification-failed', gapNotice: '全部候选未通过确定性校验' };
}
// 3) 仍有多解时,交由仲裁者基于证据裁决,并保留分歧
const winner = verified[0];
return { winner, reason: 'evidence-based-arbitration', gapNotice: `存在 ${verified.length} 个通过校验的候选,需人工复核` };
}
export async function editWithOptimisticLock(
fileUri: string,
expectedVersion: string,
apply: (content: string) => string,
maxRetry = 3
): Promise<{ ok: boolean; version?: string; content?: string }> {
for (let attempt = 0; attempt < maxRetry; attempt++) {
const current = await readFileWithVersion(fileUri); // { version, content }
if (current.version !== expectedVersion) {
return { ok: false }; // 版本冲突:交由上层重算,交给锁之前先重读
}
const next = apply(current.content);
const committed = await writeFileIfVersionUnchanged(fileUri, current.version, next);
if (committed) return { ok: true, version: committed, content: next };
// 提交失败说明期间有他人写入:重新读取并重算,而非加锁
}
return { ok: false };
}
配套规约:结论必须带证据引用(这既是仲裁的前提,也是人工复核的入口);失败必须分三级(可重试 / 需重规划 / 需人工),三级走三条不同的恢复路径。
面试官视角:
考察意图: 这是多 Agent 协作方向的经典深水区题,考察候选人是否真正处理过并发写入与冲突消解,还是只停留在「主 Agent 调子 Agent」的串行心智模型。评分重点在是否会区分三类冲突,以及能否说清乐观并发与分布式锁的适用边界。
深度追问链:
- 追问 1: 多个 Agent 同时改同一个文件,你第一反应是上分布式锁吗?
- 答题切入点: 不。先判断操作是否可重放——代码/文本编辑通常可重放,用版本号 + 冲突重算更划算(无锁开销、冲突少时吞吐更高);只有扣减类不可逆操作才考虑悲观锁,并补齐租约续期与脑裂兜底。
- 追问 2: 三个 Agent 给出三个不同答案,票数是 2 比 1,你采信多数吗?
- 答题切入点: 不一定。多数票的前提是错误彼此独立,若三者是同一模型、同一上下文、同一类提示词,它们会一起错;应先做确定性校验(Schema / 单测 / 规则),校验后仍多解才仲裁,且仲裁依据必须是独立证据而非一致性。
- 追问 3: 子 Agent 超时了,你返回什么?
- 答题切入点: 按失败分级处理——可重试走带抖动的指数退避且设上限,不可重试立刻失败上报;能降级则返回部分结果,但必须在结果里显式标注哪些结论缺失证据,绝不静默返回空。
延伸阅读:
让两个 Agent 互相挑错来提升准确性,怎么设计才不是「互相附和」?
答案
核心结论:
辩论要有价值,必须满足信息不对称。两个 Agent 若共享同一份上下文、用同一个模型、同一条检索路径,它们不会互相纠错,只会互相强化同一个错误——这是把辩论当正确性保障时最危险的误区。
三个必要设计点:
- 角色不对称:一方是「提出者」(构造主张),一方是「证伪者」(只负责找反例,不负责给结论);
- 证据不对称:证伪方必须基于不同的证据来源(不同检索库、不同文件、不同数据源)去攻击,不能只是复述对方的论证;
- 收敛判据可证伪:证伪方必须给出可复现的反例才能判定驳回;拿不出反例只能记「未证伪」,不能默认通过。
关键工程细节:必须并行独立生成后再交换意见。串行辩论会让后一个 Agent 被前一个的结论锚定(anchoring),收敛速度快了,但准确率反而下降。
原理解析:
1. 为什么串行辩论会退化
2. 信息不对称的三个来源
| 不对称维度 | 做法 | 目的 |
|---|---|---|
| 证据源 | 不同检索库 / 不同文件 / 不同数据源 | 避免共享同一错误 |
| 角色 | 提出者 vs 证伪者(证伪者不负责给结论) | 避免锚定与折中 |
| 模型 | 尽量不同模型或不同温度 | 避免同一偏好系统性偏移 |
3. 收敛判据必须可证伪
「未找到反例」和「确认正确」是两件不同的事。工程上必须区分三种终态:
- 已驳回:证伪方给出可复现的反例(反例输入 + 实际输出 + 期望输出);
- 未证伪:证伪方在给定的证据源与预算内没找到反例——不等于正确,只代表「本轮未被推翻」;
- 待人工:双方证据冲突且无法自动裁决。
把「未证伪」直接当成「通过」,是辩论机制最容易被绕过的地方。
4. 成本与适用范围
辩论把 Token 成本乘以轮数(双方 × 轮次),且时延同步放大。因此它应当是分层策略的一层:
- 低风险 + 有确定性校验(类型、Schema、单测)→ 不辩论,直接用确定性校验,成本近乎为零;
- 中风险 + 证据可查 → 单 Agent + 事后抽检;
- 高风险 + 事实性结论 → 才走辩论,且限定轮数与预算。
规范实现:
// debate.ts
export interface DebateRound {
claim: string;
evidence: Array<{ source: string; locator: string; excerpt: string }>;
counterExample?: { input: string; actual: string; expected: string }; // 可复现反例
}
export type VerdictKind = 'rejected' | 'not-falsified' | 'needs-human';
export async function debate(
claim: string,
finder: (c: string) => Promise<DebateRound[]>,
reproduce: (ce: DebateRound['counterExample']) => Promise<boolean>,
budget: { maxRounds: number; maxSources: number }
): Promise<{ kind: VerdictKind; reason: string }> {
const rounds = await finder(claim);
for (const r of rounds) {
if (!r.counterExample) continue;
// 反例必须可复现,才算真正驳回
if (await reproduce(r.counterExample)) {
return { kind: 'rejected', reason: `反例可复现:${r.counterExample.input}` };
}
}
if (rounds.length >= budget.maxRounds) {
return { kind: 'needs-human', reason: '达到轮次预算且未产出可复现反例' };
}
// 关键:未找到反例 != 正确
return { kind: 'not-falsified', reason: `在 ${rounds.length} 个证据源内未找到可复现反例,不代表结论正确` };
}
调度上的两个硬约束:生成阶段并行、交换阶段串行;以及证伪方的证据源集合与提出方不相交——这个约束需要由编排器强制,而不是靠提示词请求。
面试官视角:
考察意图: 考察候选人是否理解「多 Agent 不等于更强的正确性」。高分回答必须点破共享上下文 + 同模型 = 互相强化错误这一机制,并给出信息不对称与可证伪收敛判据两条设计原则。
深度追问链:
- 追问 1: 两个 Agent 用同一个模型、同一份文档,辩论还有意义吗?
- 答题切入点: 意义有限。共享输入时它们的错误高度相关,多数票会稳定收敛到错误答案;最多能提高表述清晰度,不能提高事实正确率。要提升正确率必须引入独立证据源或独立模型。
- 追问 2: 证伪方怎么证明它真的找过,而不是随便说「没找到问题」?
- 答题切入点: 要求证伪方输出可复现的反例三元组(输入 + 实际输出 + 期望输出)并由独立执行器复现验证;拿不出反例只能记「未证伪」,与「通过」严格区分。
- 追问 3: 辩论的 Token 成本怎么控制?
- 答题切入点: 分层策略——能用确定性校验(类型/Schema/单测)就不辩论;低风险走单 Agent + 抽检;只对高风险事实性结论走辩论,并设轮次与证据源预算,超预算转人工。
延伸阅读:
要同时跑上千个 Agent 沙箱实例,资源池和调度怎么设计?沙箱怎么选型?
答案
核心结论:
1. 沙箱选型看三件事:是否执行不可信代码、是否需要强隔离、启动延迟预算。
| 隔离层级 | 机制 | 隔离强度 | 冷启动 | 适用 |
|---|---|---|---|---|
| 进程级 | 子进程 + seccomp / namespace | 弱(逃逸风险高) | 毫秒 | 自有可信代码、纯计算 |
| 容器级 | 容器 + cgroup + capability | 中 | 十~百毫秒 | 受控代码、一般工具执行 |
| microVM 级 | Firecracker / gVisor | 强(硬件级隔离) | 百毫秒~秒 | 不可信代码、第三方插件、公开执行服务 |
Agent 场景默认要执行模型生成的代码,因此至少容器级,公开服务应上 microVM。
2. 资源池要按「形状」切分,而不是平均分配:CPU 密集(编译、跑测试)、IO 密集(等待外部 API)、内存密集(大上下文、长时任务)三类负载的瓶颈完全不同,混在一个池里必然出现「IO 任务占着 CPU 配额空转」。
3. 调度的真正瓶颈是冷启动,不是算力:每次任务现拉镜像,现宿主机会让 P99 时延完全失控。必须做 warm pool 预热 + 快照恢复 + 抢占式回收。
4. 沙箱状态不可信:沙箱内的文件、进程、内存都不能作为续跑依据;断点续跑必须把状态外置到编排器侧。
原理解析:
1. 调度器拓扑
2. 为什么「平均分配」必然失败
一个池里既有「跑 30 秒编译」的任务,也有「等外部 API 返回 10 分钟」的任务。固定配额下,等待型任务会长时间占着内存与 CPU 配额而不产生计算,表现为整体 P99 变差但 CPU 利用率不高。因此隔离维度必须对应瓶颈资源:等待型单独排队、计算型按核数分配、内存型按内存配额分配。
3. 冷启动的量级问题
现拉镜像的启动开销通常是数十到数百毫秒,一旦并发上千,镜像仓库限流、存储 IO 与网络都会成为新的瓶颈,成为真正的 P99 杀手。工程手段是 warm pool 预热 + 快照(snapshot/restore)+ 按形状预留实例。
4. 安全基线(沙箱必须默认不可信)
- 默认禁网,只放通白名单出口(模型 API、必要的包仓库);
- 禁挂载宿主机敏感路径,只挂临时工作目录;
- 每次任务结束销毁实例,状态全部外置;
- 设置 CPU / 内存 / 墙钟时间三重限额,任一超限即终止并回收。
规范实现:
// sandbox-scheduler.ts
export type WorkloadShape = 'cpu' | 'io' | 'mem';
export interface SandboxSpec {
shape: WorkloadShape;
cpuMillicores: number;
memoryMb: number;
wallClockMs: number;
networkEgress: string[]; // 白名单出口,默认空
image: string;
}
export interface SandboxHandle {
id: string;
run<T>(cmd: string[], opts?: { timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }>;
destroy(): Promise<void>;
}
export interface Scheduler {
acquire(spec: SandboxSpec): Promise<SandboxHandle>;
}
export function createScheduler(pools: Record<WorkloadShape, SandboxHandle[]>, limits: {
maxPerShape: Record<WorkloadShape, number>;
warmTarget: number;
}): Scheduler {
return {
async acquire(spec) {
const pool = pools[spec.shape];
// 1) 优先复用 warm 实例(快照恢复,避免冷启动)
const reusable = pool.find(s => !s.busy);
if (reusable) {
reusable.busy = true;
await reusable.restore(); // 快照恢复,而非重新拉镜像
return wrapWithLimits(reusable, spec);
}
// 2) 池空则受限新建;新建失败不无限重试,转排队
if (pool.length < limits.maxPerShape[spec.shape]) {
const fresh = await createSandbox(spec.image);
pool.push(fresh);
return wrapWithLimits(fresh, spec);
}
// 3) 超出配额:排队等待并施加背压,而不是无限创建
await backoff(spec.shape);
return createScheduler(pools, limits).acquire(spec);
}
};
}
// 三重限额:任一超限立即终止并回收
function wrapWithLimits(handle: SandboxHandle, spec: SandboxSpec): SandboxHandle {
return {
id: handle.id,
run: (cmd, opts) => handle.run(cmd, { timeoutMs: opts?.timeoutMs ?? spec.wallClockMs }),
destroy: async () => { await handle.destroy(); handle.busy = false; }
};
}
关键约定:状态必须外置。 沙箱实例在任务之间可被销毁重建,续跑所需的中间产物、对话状态、任务进度一律写入外部存储(对象存储 / KV),由编排器在恢复时重新注入沙箱。
面试官视角:
考察意图: 考察候选人是否具备「把 Agent 当生产系统来运维」的意识。高分回答必须区分隔离层级(进程/容器/microVM)并说明选择依据,同时指出冷启动与资源形状才是规模化调度的真正难点。
深度追问链:
- 追问 1: 用户上传的代码让 Agent 执行,沙箱选什么隔离级别?
- 答题切入点: 至少容器级并默认禁网、只放通白名单出口;面向多租户公开服务时上 microVM(Firecracker / gVisor);同时说明进程级隔离对不可信代码不够的原因(逃逸面与内核共享)。
- 追问 2: 并发从 100 涨到 1000,延迟先崩了,你会先查什么?
- 答题切入点: 先看冷启动占比(镜像拉取、存储 IO、镜像仓库限流)而不是先看 CPU;再看资源形状是否混池导致等待型任务占用配额;措施是 warm pool + 快照恢复 + 按形状分池。
- 追问 3: 任务执行到一半机器挂了,怎么续跑?
- 答题切入点: 沙箱状态不可信,不能指望实例自愈;必须把断点、产物、日志外置到编排器侧,实例重建后由编排器重新注入状态;同时用幂等设计保证重放安全。
延伸阅读:
Agent 跑了 20 分钟还没结束,用户等不及了怎么停?超时该怎么设?
答案
核心结论:
「超时」不是给整条链路配一个数字,而是三层预算;「停止」也不是简单地杀掉进程,而是先落盘再停。四个要点:
- 三层超时预算:单步超时(一次工具调用,防止单个挂死调用拖垮全链)、阶段超时(一段规划或一个阶段的墙钟)、整体墙钟超时(整个任务的 deadline)。用户感知的是第三层,但它必须由上往下分配而来。
- 预算自上而下切分:整体预算先切给各阶段,阶段再切给单步;子级预算之和不得超过父级,并且要预留模型思考与断点落盘的时间——预算耗尽时如果没时间写状态,续跑能力就废了。
- 渐进式提醒,而不是到点就砍:先用软提示(「还需要约 3 分钟,继续吗?」)征询用户,用户明确取消再硬中断;硬中断必须先落盘 checkpoint 再停,否则「停止」等于「丢失」。
- 用户取消与系统超时语义不同:用户取消是主动意图——立即停、保留部分结果、明确告知「已完成到哪一步、产物在哪」;系统超时是兜底——先落盘、标记为可续跑,等下次机会接上。
- 挂起时间不计入预算:等待人工审批(HITL)时任务处于挂起态,这段时间不应消耗墙钟预算,否则人下班去审批,任务就被误判超时杀掉;审批本身另有 SLA。
原理解析:
1. 三层预算的层级关系
2. 为什么一个总超时不够
单一超时值无法回答「时间到底浪费在哪」。三层预算让超时变成可定位的信号:单步超时说明某个工具挂死了(多半是下游问题);阶段超时说明这个阶段的计划太大或卡在某个环节;整体超时说明任务规模本身超出预期,该考虑拆任务或改异步模式。把三者混成一个数,出问题时只知道「超时了」,不知道该修哪里。
3. 取消信号的传播路径
用户点了「停止」,这个意图必须穿透整条链路才能真正停下:前端取消按钮 → 编排器(收到 AbortSignal)→ 正在执行的工具调用(中断 HTTP 请求或终止进程)→ 沙箱实例(销毁或归还资源池)→ 已挂起的 HITL 审批(撤销 pending 请求)。只在前端停止接收流式输出,任务其实还在后台跑——这是最常见的「假取消」。
4. 挂起时钟的暂停与恢复
HITL 审批是「等人」,不是「在算」。工程做法是把任务状态机显式建模为 running / suspended / cancelled / finished 四个状态之一:进入 suspended 时暂停墙钟计时与预算扣减,恢复时按暂停时长回补预算。否则一个「等人审批 4 小时」的任务,会在人回来之前就被超时杀掉。
5. 与「重试风暴」的边界
重试风暴处理的是下游错误(失败后怎么重试、怎么退避、怎么熔断);本题处理的是任务生命周期与用户意图(谁来终止、什么时候终止、终止时留下什么)。两者的交点是:重试消耗的预算必须计入同一套预算,否则一个卡死的下游能把整个任务耗到超时。
规范实现:
// agent/timeout-budget.ts
export type RunPhase = 'running' | 'suspended' | 'cancelled' | 'finished';
export interface Budget {
taskDeadlineMs: number;
phaseDeadlineMs: number;
stepDeadlineMs: number;
}
export class TimeoutBudget {
private startedAt = Date.now();
private pausedAt?: number;
private pausedTotalMs = 0;
constructor(private budget: Budget) {}
// 挂起期间不计入墙钟(等待人工审批不算超时)
public suspend(): void { this.pausedAt = Date.now(); }
public resume(): void {
if (this.pausedAt) { this.pausedTotalMs += Date.now() - this.pausedAt; this.pausedAt = undefined; }
}
public elapsedMs(): number { return Date.now() - this.startedAt - this.pausedTotalMs; }
public taskExpired(): boolean { return this.elapsedMs() > this.budget.taskDeadlineMs; }
public phaseExpired(phaseStartedAt: number): boolean {
return Date.now() - phaseStartedAt > this.budget.phaseDeadlineMs;
}
public stepExpired(stepStartedAt: number): boolean {
return Date.now() - stepStartedAt > this.budget.stepDeadlineMs;
}
}
// 取消信号从编排器穿透到工具与沙箱
export class CancellableRunner {
private controller = new AbortController();
private phase: RunPhase = 'running';
constructor(private budget: TimeoutBudget, private onCheckpoint: (reason: string) => Promise<void>) {}
public get signal(): AbortSignal { return this.controller.signal; }
public get currentPhase(): RunPhase { return this.phase; }
// 用户主动取消:先落盘再停,保留部分结果
public async cancelByUser(partialResult: string): Promise<void> {
this.phase = 'cancelled';
await this.onCheckpoint('user-cancel'); // 先落盘
this.controller.abort(); // 再中断
void partialResult; // 部分结果由调用方带回给用户
}
// 系统超时:同样先落盘,但标记为可续跑
public async cancelByTimeout(): Promise<void> {
this.phase = 'cancelled';
await this.onCheckpoint('timeout');
this.controller.abort();
}
// HITL 挂起:暂停计时,不消耗预算
public async waitForApproval(approval: Promise<void>): Promise<void> {
this.phase = 'suspended';
this.budget.suspend();
try {
await approval;
} finally {
this.budget.resume();
this.phase = 'running';
}
}
}
前端侧的配合:取消按钮必须区分「停止生成」(仅停止流式输出)与「终止任务」(真正取消后端任务,并提供「保留部分结果」的选项)。两者在 UI 上不要用同一个按钮,否则用户会以为任务停了,其实还在后台烧 Token 与算力。
面试官视角:
考察意图: 考察候选人是否理解长任务的生命周期管理。高分回答必须能拆出三层预算、区分用户取消与系统超时、指出挂起时间不计入预算,并强调取消信号要穿透到工具与沙箱(而不是只在前端停流)。
深度追问链:
- 追问 1: 超时时间你打算怎么定?
- 答题切入点: 不要拍脑袋定一个数;从历史真实分布(各阶段 P95/P99 时延)出发自上而下分配并留余量;单步预算要覆盖正常长尾调用,阶段预算要防止单个阶段吃掉全部时间。
- 追问 2: 用户点了停止,后端任务真的停了吗?
- 答题切入点: 需要
AbortSignal从编排器穿透到工具调用与沙箱销毁,并且先落盘 checkpoint 再 abort;只停前端流式输出是「假取消」,任务仍在消耗 Token 与算力。
- 答题切入点: 需要
- 追问 3: 任务挂在等人工审批,这时要不要计入超时?
- 答题切入点: 不计入——挂起态要显式建模并暂停墙钟计时,审批另设 SLA;否则人不在的时候任务会被误杀。同时取消动作要能撤销 pending 的审批请求。
延伸阅读:
让 Agent 多次执行同一个任务,结果时好时坏,怎么度量稳定性、定位漂移来源?
答案
核心结论:
「能跑通」和「稳定」是两件事。一次成功只说明能力够用,稳定性是多次执行之间的结果方差。要把它度量出来,先做一次分类:
- 先分清三种失败:能力不足(重试就会好)、不稳定(重试结果不一致)、确定性失败(每次都一样地错)。三者的修法完全不同:能力不足要补能力,确定性失败要修 bug/提示词,不稳定要定位漂移源。
- 度量口径要三个维度,只看成功率会被平均分掩盖:
- 任务级成功率:多次执行的通过比例;
- 结果一致率:同一输入的多次输出在关键字段上是否等价(不是逐字相同,是语义等价);
- 过程方差:步数、Token 数、工具调用序列的波动。
- 测稳定性之前必须先固定变量:把 temperature 降到 0、锁定模型版本与路由、锁定检索快照与工具返回值。若固定后仍然抖动,才是系统性问题;否则你测的是采样随机性,不是 Agent 稳定性。
- Agent 的不稳定源比普通服务多一层——轨迹分叉:同样的任务走了不同的工具顺序、不同的记忆写入、不同的重试路径,最终输出就不同。所以定位要靠轨迹对比(trace diff),不能只对比最终答案。
门禁要卡最差分位:同一 Golden Case 跑 N 次(如 5 次),要求成功率 ≥ X 且关键字段一致率 ≥ Y。平均值掩盖波动——「5 次里 4 次对」的平均分是 80%,但用户每 5 次就撞一次。
原理解析:
1. 漂移来源的分层排查
2. 为什么「固定变量」这一步不能省
Agent 系统的默认配置就带随机性:采样参数、检索排序的并列打破、工具返回的时间戳与分页游标、并发请求的到达顺序。不固定这些就直接测,你量到的是这些随机源的方差,而不是 Agent 决策的稳定性。真实工程里常见的结论是「把 temperature 设 0 后稳定性从 70% 提到 95%」——那说明瓶颈从来不在模型能力,而在配置。
3. 一致性的判定要比「逐字相同」宽松
Agent 的自然语言输出逐字不同的概率很高,用字符串相等判一致会得到「几乎不稳定」的荒谬结论。正确的判据是关键字段/关键结论的等价性:结构化输出比字段、引用的事实是否一致、是否命中了必需的步骤。关键字段可以事先在用例里声明(must_match / must_not_match / must_call_tools)。
4. 不稳定和「过度稳定」都要警惕
输出完全一致但每次都错,是确定性失败(更危险,因为成功率 100% 会掩盖它)。所以稳定性门禁必须与正确性门禁并存:一致率高但正确率低,说明它稳定地错;正确率高但一致率低,说明它靠运气。
规范实现:
// agent-stability.ts
export interface StabilityCase {
id: string;
input: string;
mustMatch?: string[]; // 关键结论必须出现
mustNotMatch?: string[]; // 关键结论禁止出现
mustCallTools?: string[]; // 必须调用过的工具
expectedFields?: Record<string, unknown>; // 结构化字段的期望值
}
export interface RunResult {
ok: boolean;
output: string;
fields?: Record<string, unknown>;
toolsCalled: string[];
steps: number;
tokens: number;
}
export interface StabilityReport {
caseId: string;
runs: number;
successRate: number; // 任务级成功率
consistencyRate: number; // 关键字段一致率
stepSpread: number; // 步数方差
firstDivergenceStep?: number; // 轨迹首个分叉点
}
// 1) 判定单次结果是否符合期望
export function judge(c: StabilityCase, r: RunResult): boolean {
if (!r.ok) return false;
for (const m of c.mustMatch ?? []) if (!r.output.includes(m)) return false;
for (const m of c.mustNotMatch ?? []) if (r.output.includes(m)) return false;
for (const t of c.mustCallTools ?? []) if (!r.toolsCalled.includes(t)) return false;
for (const [k, v] of Object.entries(c.expectedFields ?? {})) {
if (JSON.stringify(r.fields?.[k]) !== JSON.stringify(v)) return false;
}
return true;
}
// 2) 关键字段一致率:以多数结果为基准(语义等价,而非逐字相同)
export function consistency(results: RunResult[]): number {
if (results.length < 2) return 1;
const sig = (r: RunResult) => JSON.stringify(r.fields ?? { text: r.output.trim() });
const counts = new Map<string, number>();
for (const r of results) counts.set(sig(r), (counts.get(sig(r)) ?? 0) + 1);
const top = Math.max(...counts.values());
return top / results.length;
}
// 3) 跑 N 次并出稳定性报告
export async function evaluateStability(
c: StabilityCase,
run: (input: string) => Promise<RunResult>,
n = 5
): Promise<StabilityReport> {
const results: RunResult[] = [];
for (let i = 0; i < n; i++) results.push(await run(c.input));
const passed = results.filter(r => judge(c, r));
const steps = results.map(r => r.steps);
const mean = steps.reduce((s, v) => s + v, 0) / (steps.length || 1);
const variance = steps.reduce((s, v) => s + (v - mean) ** 2, 0) / (steps.length || 1);
return {
caseId: c.id,
runs: n,
successRate: passed.length / n,
consistencyRate: consistency(results),
stepSpread: Math.sqrt(variance)
};
}
// 4) 门禁:卡最差分位,不用平均值
export function gate(reports: StabilityReport[], minSuccess = 0.8, minConsistency = 0.8): boolean {
return reports.every(r =>
r.successRate >= minSuccess && r.consistencyRate >= minConsistency
);
}
**轨迹对比(找第一个分叉点)**的实现要点:把两次执行的工具调用序列按序对齐,找出第一个不同的位置——那一步就是漂移源,而不是把差异平均到全程:
export function firstDivergence(a: RunResult, b: RunResult): number | undefined {
const n = Math.min(a.toolsCalled.length, b.toolsCalled.length);
for (let i = 0; i < n; i++) {
if (a.toolsCalled[i] !== b.toolsCalled[i]) return i;
}
return a.toolsCalled.length !== b.toolsCalled.length ? n : undefined;
}
面试官视角:
考察意图: 考察候选人是否理解**「可用」与「稳定」是两个维度**,以及是否会用方差与一致率而不是单次成功率来描述 Agent 质量。高分回答必须包含「先固定变量再测」这一步,以及「定位漂移要看轨迹分叉而非只看最终答案」。
深度追问链:
- 追问 1: 你的 Agent 上线后成功率 85%,但用户反馈「同一个问题每次答案不一样」,你怎么查?
- 答题切入点: 先固定变量(temperature 降到 0、锁模型版本与路由、锁检索快照与工具返回),再重测;若仍不一致,按模型/上下文/工具/轨迹四层排除,并用轨迹对比找第一个分叉点。关键是先确认抖动来自决策还是来自采样。
- 追问 2: 一致率要卡多少?成功率 100% 就说明稳定吗?
- 答题切入点: 一致率与正确率必须并存。成功率 100% 也可能是「稳定地错」——那更危险,会掩盖缺陷。门禁要同时卡成功率与关键字段一致率,且用多次执行的最差表现判定,不用平均值。
- 追问 3: 稳定性测试要跑多少次才够?
- 答题切入点: 次数取决于「允许的失败概率」与「观测到的成功率」。用统计口径反推:在成功率 p 下跑 n 次仍没暴露问题的概率是 (1-p)^n;要把它压到可接受阈值以下,n 才能定下来。实践上关键用例跑 5 次、抽样用例更多次。
延伸阅读:
Agent、Orchestrator、Harness 和 Runtime 到底怎么分工?为什么要拆成四层?
答案
核心结论:
-
四层各管一件事,边界如下:
- Agent:管语义决策——下一步该干什么(调哪个工具、委派给谁、还是收尾);
- Orchestrator:管依赖与分派——谁先谁后、哪些可并行、某一步的产出喂给哪一步;
- Harness:管模型与工具之间的受控循环——怎么调、什么不许调、什么时候必须停;
- Runtime:管生命周期与恢复——状态存哪、进程挂了怎么续、失败怎么重启。
-
为什么不能一个
while循环干完:把语义决策、工具调度、状态持久化与错误恢复塞进同一个循环,会导致三件事同时崩——任何一处改动都要动全流程;语义决策不可测(LLM 调用与 I/O 混在一起就没法做回归评测);状态不可恢复(进程一挂,运行态与持久态纠缠在一起,无从接续)。 -
控制面 vs 执行面:Orchestrator + Runtime 是控制面(决定拓扑、顺序、生命周期、重试与恢复);Agent + Harness 的模型交互是执行面。分离后的最大收益是——同一套执行面可以被不同控制面调度:DAG 编排器、多 Agent 委派器、单 Agent 循环,本质是控制面的不同实现。
-
一句话判断归属:新需求问自己四句话——
- 「它需要理解用户意图吗?」需要 → Agent;
- 「它需要决定先做哪一件吗?」需要 → Orchestrator;
- 「它需要约束模型怎么用工具吗?」需要 → Harness;
- 「它需要在进程死后把状态捞回来吗?」需要 → Runtime。
-
与本库既有考点的边界:「Agent = Model + Harness」那一题讨论的是能力归属(哪些能力属于模型、哪些属于 Harness);本题讨论的是运行时职责分层与边界划分。前者回答「能力从哪来」,后者回答「代码怎么分层」。
-
边界声明:这四层是工程抽象,不是任何标准协议的定义。各框架命名不同(LangGraph 的 StateGraph 与 Checkpointer、OpenAI Agents SDK 的 Runner 与 Session),但职责切分的逻辑一致——面试时说清「按职责切、按可测性验」比背框架名更重要。
原理解析:
1. 四层全景
2. 每层的输入、输出与典型失败模式
| 层 | 输入 | 输出 | 典型失败模式 | 可测性 |
|---|---|---|---|---|
| Agent | 意图 + 观察 | 决策 | 意图误判、工具选错 | 高(可用固定用例回归) |
| Orchestrator | 依赖图 | 执行计划 | 拓扑错误、依赖误判 | 高(纯确定性逻辑) |
| Harness | 决策 + 运行上下文 | 一步结果 | 越权调工具、死循环 | 中(可注入故障) |
| Runtime | 任务状态 | 恢复后的状态 | 状态丢失、恢复不一致 | 高(可用崩溃注入验证) |
3. 混层的三个典型后果
- 把决策写进 Harness:Harness 里塞满 if/else 业务判断,于是 Harness 不可复用——换一种业务流程就要改底层循环。症状:底层模块被业务需求反复改动。
- 把状态持久化写进 Agent 逻辑:Agent 每次决策都顺手写库,于是「决策」与「副作用」纠缠。症状:重放同一条轨迹会产生重复副作用,评测无法复现。
- 把恢复逻辑写进 Orchestrator:编排器里塞满重试与恢复分支。症状:编排逻辑本身变得无法测试,且不同编排方式各写一套恢复逻辑,行为不一致。
4. 分离之后最大的红利:执行面可复用
一套写好的执行面(Agent + Harness),可以被三种控制面直接复用:单 Agent 的 ReAct 循环(控制面即一个顺序调度器)、固定 DAG 工作流(控制面是拓扑排序)、多 Agent 委派(控制面是任务图与冲突仲裁)。控制面换型不影响执行面,这正是企业里 Agent 平台能同时承载多种形态的原因。
规范实现:
下面用接口把四层的边界「焊死」——注意每个接口只暴露该层该知道的东西:
// layers.ts —— Agent / Orchestrator / Harness / Runtime 的职责边界
// 1) Agent:只做语义决策,不碰 I/O 之外的副作用
export type Decision =
| { kind: 'call-tool'; tool: string; args: Record<string, unknown> }
| { kind: 'delegate'; target: string; task: string }
| { kind: 'finish'; answer: string }
export interface Agent {
decide(input: { intent: string; observations: string[] }): Promise<Decision>
}
// 2) Orchestrator:只做依赖与分派,不理解业务语义
export interface DependencyGraph {
nodes: { id: string; dependsOn: string[] }[]
}
export interface Plan {
steps: { id: string; after: string[] }[]
}
export interface Executor {
(step: { id: string; input: unknown }): Promise<unknown>
}
export interface Orchestrator {
plan(graph: DependencyGraph): Promise<Plan>
dispatch(plan: Plan, executor: Executor): Promise<Record<string, unknown>>
}
// 3) Harness:受控的工具循环,含工具白名单与停止条件
export interface RunContext {
step: number
maxSteps: number
toolAllowlist: string[]
fingerprints: string[] // 连续相同调用的指纹,用于无进展检测
}
export type StepOutcome =
| { kind: 'observation'; content: string }
| { kind: 'stop'; reason: 'done' | 'budget' | 'no-progress' }
export interface Harness {
run(decision: Decision, ctx: RunContext): Promise<StepOutcome>
}
// 4) Runtime:只管生命周期与恢复,不参与决策
export interface Runtime {
checkpoint(taskId: string, state: unknown): Promise<void>
restore(taskId: string): Promise<unknown | null>
withRetry<T>(op: () => Promise<T>, attempts: number): Promise<T>
}
边界怎么体现在类型上:Agent.decide 的输入里没有工具句柄,它只能「说」要调什么,不能直接调;Harness.run 的输入里没有业务语义,它只知道一次决策;Runtime 的方法签名里完全没有决策类型。类型签名本身就是职责边界的文档——想越界就得改接口,而不是在实现里偷偷加逻辑。
面试官视角:
考察意图: 区分「用过 Agent 框架」与「理解 Agent 系统的分层原则」。这题在 Agent 平台与 AI 架构岗是高频「大局观」题,评分重点在于能否讲清边界并说明分层的收益,而不是背框架名。
评分 Rubric:
- 必答:能说出四层各自的职责;能解释为什么不能一个循环干完;能区分控制面与执行面。
- 加分项:能举出混层的具体后果;能说明分离后执行面可复用;能指出「能力归属」与「职责分层」是两类不同问题。
- 常见误区:把 Harness 说成「界面」;把 Orchestrator 说成「任务队列」;认为 Runtime 是可选的运维组件;只会报框架名讲不出职责。
深度追问链:
- 追问 1:让你把现有单 Agent 脚本拆成四层,第一刀切在哪?
- 答题切入点:先把「决策」与「副作用」分开(Agent 不直接写库),再把停止条件与工具白名单收进 Harness;这两刀收益最大,因为它们直接决定了能不能做回归评测与安全管控。
- 追问 2:为什么说「执行面可复用」是分层的核心收益?
- 答题切入点:同一套 Agent + Harness 能被顺序循环、DAG 编排、多 Agent 委派三种控制面驱动,说明业务形态变化不需要改底层模型交互逻辑;反之若决策写死在下层,换形态就得重写。
- 追问 3:Runtime 这层是不是可以省掉,用数据库兜底就行?
- 答题切入点:不能省。数据库只提供存储,不提供状态一致性语义(哪一份是权威快照、如何在崩溃后判定恢复到哪一步、重放会不会产生重复副作用)。这些必须由 Runtime 明确定义,否则恢复就是靠猜。
延伸阅读:
要训一个会调工具的 Agent,多步轨迹的 Reward 怎么设计?信号稀疏时信用怎么分配?
答案
核心结论:
-
Reward 必须分层设计,不能只有终局对错。工程上最实用的四层:
- 结果奖励:任务是否完成、最终产物是否正确;
- 过程奖励:每一步是否合法、是否真的推进了任务;
- 格式与工具合法性奖励:参数是否可解析、工具是否在允许列表内;
- 成本惩罚:延迟与 token 开销。 只用第一层,模型拿到的信号极其稀疏,绝大多数轨迹得不到任何梯度。
-
信用分配(credit assignment)是这道题的核心难点:终局一个 0/1 信号无法告诉模型「第 7 步的工具选错了」。必须把奖励摊回到具体步骤,并明确「谁承担多少责任」。
-
粒度选择决定样本效率与稳定性:
- trajectory 级:最稳,但信号最稀疏,样本效率最低;
- token 级:最稠密,但噪声大,且极易被 hack;
- turn(一步动作)级:工程折中——与 Agent 的决策单元对齐,是当前主流做法。
-
Reward Hacking 是首要风险:一旦奖励函数可被绕过,模型会去优化指标而不是完成任务。最常见的三类:钻格式空子(只求参数合法,不管做没做)、拖长轨迹骗取过程奖励(无意义调用堆步数)、用空转步骤换免责(触发失败惩罚就立刻收工)。
-
必须分清训练指标与线上成功:训练 reward 上升不等于线上任务成功率上升。Reward 是代理量(proxy),线上要看端到端任务成功率与人工可接受率;两者背离时,要改的是奖励设计而不是训练超参。
-
与本库既有考点的边界:「过程指标与 PRM」那一题讲的是评测侧——怎么给已经跑完的轨迹打分;本题讲的是训练侧——怎么设计奖励让策略学会。PRM 在两侧都会出现,但用途完全不同:评测侧当打分器,训练侧当奖励模型。
-
边界声明:具体 RL 算法(PPO、GRPO 一类的策略梯度方法)的推导属于算法岗深水区,Agent 岗更该答奖励项设计与防 hack 机制。
原理解析:
1. 奖励信号从哪来到哪去
2. 四类奖励项的工程取值
| 奖励项 | 度量什么 | 权重取向 | 典型副作用 |
|---|---|---|---|
| 结果奖励 | 任务是否完成、产物是否正确 | 权重最高,决定主目标 | 稀疏,单独用学不动 |
| 过程奖励 | 每步是否合法、是否推进 | 中等,充当稠密信号 | 易被「刷步数」hack |
| 格式与合法性奖励 | 参数可解析、工具在白名单内 | 偏低,作为底线约束 | 可能被用来「只做格式正确的无用功」 |
| 成本惩罚 | 延迟、token、工具调用次数 | 小幅,防止无止境消耗 | 权重过大则模型倾向不做事 |
3. 信用分配怎么落
终局失败时,责任通常集中在第一个出错的步骤;在它之前的步骤通常不背锅(它们在当时的信息下是合理的),在它之后的步骤要按比例分担(因为它们本可以走别的路)。这是一种常见的工程折中——不追求理论上最优的归因,只要求责任落点足够锐利,让梯度真正打到该改的那一步。
4. 稀疏信号怎么破:三条常规手段
- 课程式任务难度:从单步任务开始,逐步加长轨迹,让稠密信号先跑起来;
- 分层奖励:先训「单步动作正确」的策略,再训长程规划,典型是过程奖励模型(PRM)先做步骤打分器;
- 奖励整形(shaping):引入与最终目标同向的中间信号(完成子目标、命中关键事实)——但必须小心,整形项一旦可被独立优化就会变成新的 hack 入口。
规范实现:
下面实现「逐项打分 + 责任落点」两件事。注意责任分配是工程折中,不是理论上最优解:
// reward.ts —— 多步工具轨迹的奖励设计与信用分配
export type StepOutcome =
| {
kind: 'tool'
tool: string
args: Record<string, unknown>
ok: boolean
tokens: number
}
| { kind: 'final'; answer: string; correct: boolean }
export interface RewardConfig {
wOutcome: number
wProcess: number
wFormat: number
wCost: number
costBudgetTokens: number
}
export interface RewardBreakdown {
outcome: number
process: number
format: number
cost: number
total: number
}
export function scoreTrajectory(
steps: StepOutcome[],
cfg: RewardConfig
): RewardBreakdown {
const last = steps[steps.length - 1]
const outcome = last?.kind === 'final' && last.correct ? 1 : 0
// 过程奖励:合法推进为正,失败为负(不给 0,避免「失败与没做事」同分)
let process = 0
for (const s of steps) {
if (s.kind === 'tool') process += s.ok ? 1 : -1
}
// 格式奖励:工具调用是否结构合法,作为底线约束
const format = steps.every(
s => s.kind !== 'tool' || (s.tool.length > 0 && s.args !== null)
)
? 1
: 0
// 成本惩罚:超出预算的部分线性计罚
const used = steps.reduce(
(sum, s) => sum + (s.kind === 'tool' ? s.tokens : 0),
0
)
const cost = Math.max(0, (used - cfg.costBudgetTokens) / cfg.costBudgetTokens)
const total =
cfg.wOutcome * outcome +
cfg.wProcess * process +
cfg.wFormat * format -
cfg.wCost * cost
return { outcome, process, format, cost, total }
}
// 信用分配:把终局信号摊回到每一步
export function assignCredit(
steps: StepOutcome[],
totalReward: number
): number[] {
const firstBad = steps.findIndex(s => s.kind === 'tool' && !s.ok)
if (firstBad === -1) {
const each = totalReward / Math.max(1, steps.length)
return steps.map(() => each)
}
const badShare = 0.6
const afterShare = (totalReward * (1 - badShare)) / Math.max(1, steps.length - firstBad)
return steps.map((_, i) => {
if (i === firstBad) return totalReward * badShare
if (i < firstBad) return 0 // 出错前的步骤在当时信息下合理,不背锅
return afterShare
})
}
注意 process 里失败步给的是负分而非 0:如果失败与「什么都没做」同分,模型会倾向于少做事——这是最容易被忽略、但最致命的奖励设计错误。
面试官视角:
考察意图: 区分「知道 Agent 要有 reward」与「能设计出可训练、可防 hack 的奖励体系」。这题在 Agent 算法与 Agent 平台岗都出现,评分重点是奖励项分解 + 信用分配 + 防 hack 意识,而不是背某个 RL 算法的推导。
评分 Rubric:
- 必答:能列出至少三类奖励项;能解释为什么只用终局奖励学不动;能说出信用分配要落到步骤粒度。
- 加分项:能区分 trajectory / turn / token 三种粒度的取舍;能识别至少两类 Reward Hacking 并给出防护;能分清训练指标与线上成功。
- 常见误区:把 reward 当成「准确率」;只讲算法不讲奖励设计;认为奖励越高线上就越好;把评测指标直接当训练奖励。
深度追问链:
- 追问 1:为什么过程奖励里失败步要给负分,不能给 0?
- 答题切入点:给 0 等于告诉模型「失败和没做事一样」,在稀疏终局信号下模型会学会少做动作来规避惩罚;负分把「做砸了」和「躺平」区分开,才让梯度指向「做对」。
- 追问 2:怎么防止模型刷步数骗过程奖励?
- 答题切入点:过程奖励必须与「推进」绑定而非与「步数」绑定——用状态增量、关键子目标命中这类进展量而非动作计数计分;同时对步数与 token 设预算上限,超预算在成本项上惩罚,让刷步数不划算。
- 追问 3:训练 reward 一直涨,线上任务成功率却掉了,你怎么排查?
- 答题切入点:这是典型的代理量失效与 Reward Hacking。先做奖励项消融,看哪一项与线上指标背离最严重;再人工抽查高 reward 的失败轨迹找可被绕过的模式;最后检查训练分布与线上分布的差异。
延伸阅读:
用户点了「确认」之后,怎么保证真正执行的内容和确认时看到的完全一致?审批后还能不能再调一次大模型?
答案
核心结论:
-
审批与执行之间存在一个 TOCTOU 窗口(检查时间 / 使用时间不一致)。用户在审批弹窗里看到的是「计划」,而真正落地的是「参数」。如果这两者是两次独立生成的产物,执行前又允许模型重新决策,就会出现用户批准了 A、系统执行了 B——这是高危写操作(发邮件、转账、删文件、改配置)最典型的线上事故。
-
第一原则:审批对象必须等于执行对象。审批那一刻把最终参数快照(工具名 + 完整 arguments + 目标资源标识)冻结下来,生成一个 approval token;执行阶段只允许执行这份快照,不再重新推理参数。审批记录里同时存一份参数的内容哈希,执行前校验哈希一致即可证明「跑的还是用户看过的那份」。
-
审批后不应再调用大模型来决定「执行什么」。重新调用会引入参数漂移——上下文变了、采样有随机性、甚至同一份 prompt 两次生成的收件人列表都不一致。重新调用大模型可以用来决定「下一步做什么」,但绝不能用来改写「已经批准的内容」。如果环境确实变了、必须改参数,那就重新走一次审批,而不是悄悄换参数。
-
幂等键是执行侧的兜底,不是审批侧的替代。写操作必须带 idempotency key,让「网络超时后重试」不会变成「重复发一封邮件」。Stripe 官方文档的表述是:当创建或更新对象时带上幂等键,连接出错时可以安全地重复请求,不会重复创建对象或重复执行更新。关键纪律:重试必须复用同一个幂等键——换键等于发起了一次全新操作。
-
审批界面展示的是最终参数,不是意图描述。展示「帮我给客户发一封说明信」等于让用户盲签;必须展示将要发出的正文全文、收件人、附件清单,以及「这将不可撤销」的明确告知。
-
审批是一次授权,不是一段状态。审批之后到执行之前如果发生任何实质变化(参数变、目标变、范围变),原审批即失效,必须重新征求同意。授权范围(哪些工具、哪些参数范围、什么时效内有效)要在审批时一并固定。
原理解析:
1. 两类「看起来一样」其实不同的东西
2. 参数漂移具体长什么样
| 漂移类型 | 触发原因 | 后果 | 对策 |
|---|---|---|---|
| 收件人变化 | 重新规划时多解析出一个同名联系人 | 邮件发错人 | 参数快照冻结 |
| 正文改写 | 采样随机性 / 上下文变化 | 用户批准的不是最终发出的内容 | 哈希校验 + 冻结 |
| 范围扩大 | 模型「顺手」多加一个附件或多一个收件人 | 超出授权 | 审批固化参数范围 |
| 工具替换 | 模型改用另一个等价工具 | 走了未审批的执行路径 | 执行端校验工具名 |
| 重复执行 | 超时后换键重试 | 重复扣款 / 重复发信 | 幂等键 + 重试预算 |
3. 审批 token 里应该装什么
一个可追溯的审批凭证至少包含:toolName(执行哪个工具)、arguments(完整参数)、argsHash(参数内容哈希)、approvedBy / approvedAt(谁、什么时候)、scope(允许的参数范围与时效)、idempotencyKey(执行时复用)。
规范实现:
下面是最小可用的「审批快照 → 执行」绑定实现:审批时冻结参数并算哈希,执行前校验哈希、复用幂等键。
interface ApprovalToken {
tokenId: string
toolName: string
arguments: Record<string, unknown>
argsHash: string
approvedBy: string
approvedAt: number
scope: { allowedTools: string[]; expiresAt: number }
idempotencyKey: string
}
async function sha256 (input: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}
/** 关键:稳定序列化,避免对象键顺序不同导致哈希不一致 */
function canonical (args: Record<string, unknown>): string {
return JSON.stringify(args, Object.keys(args).sort())
}
/** 审批阶段:冻结参数,生成 token */
export async function issueApproval (p: {
toolName: string
arguments: Record<string, unknown>
approvedBy: string
allowedTools: string[]
ttlMs: number
}): Promise<ApprovalToken> {
const argsHash = await sha256(canonical(p.arguments))
return {
tokenId: crypto.randomUUID(),
toolName: p.toolName,
arguments: p.arguments, // 快照,不是引用
argsHash,
approvedBy: p.approvedBy,
approvedAt: Date.now(),
scope: { allowedTools: p.allowedTools, expiresAt: Date.now() + p.ttlMs },
idempotencyKey: await sha256(argsHash + p.toolName) // 参数决定幂等键
}
}
/** 执行阶段:只允许执行快照里的参数;任何不一致都必须重新审批 */
export async function executeApproved (
token: ApprovalToken,
run: (toolName: string, args: Record<string, unknown>, idempotencyKey: string) => Promise<unknown>
): Promise<unknown> {
if (Date.now() > token.scope.expiresAt) {
throw new Error('APPROVAL_EXPIRED: 授权已过期,必须重新审批')
}
if (!token.scope.allowedTools.includes(token.toolName)) {
throw new Error('TOOL_NOT_APPROVED: 工具不在授权范围内')
}
const actual = await sha256(canonical(token.arguments))
if (actual !== token.argsHash) {
throw new Error('ARGS_TAMPERED: 参数与审批快照不一致,必须重新审批')
}
// 重试必须复用同一把幂等键
return run(token.toolName, token.arguments, token.idempotencyKey)
}
三处防线各管一件事:过期检查管时效,工具白名单管范围,哈希比对管内容。注意这里刻意没有「重新调用模型」的分支——因为执行端一旦允许重新推理参数,整个绑定关系就失效了。
面试官视角:
考察意图: 考察候选人是否意识到审批与执行之间存在一致性缝隙,以及能否给出**「冻结快照 + 幂等兜底」这种可落地的机制**,而不是停留在「加个确认弹窗」。这题是 Agent 工程岗的经典陷阱题,高分回答必须能区分「重新调用大模型来决定下一步」与「重新调用大模型来改写已批准内容」。
评分 Rubric:
- 必答:识别 TOCTOU;提出审批冻结参数快照 + 内容哈希;执行端禁止改参数;写操作必须幂等且重试复用同一键;参数变化必须重新审批。
- 加分项:能指出「重新调用大模型」只可用于决定下一步而非改写已批准内容;能说清幂等键换键等于新操作;能讲出审批范围(工具、参数范围、时效)要一并固定。
- 常见误区:说「确认后再让模型检查一遍」(等于重新漂移);说「加个二次确认就行」(没解决 TOCTOU);把幂等键当审批机制;把「用户确认」当成一次性布尔值而不记录具体内容。
深度追问链:
- 追问 1:如果审批后确实需要重新规划(比如用户临时改了需求),流程该怎么走?
- 答题切入点:重新审批。原 token 作废,重新生成参数快照并再次征求同意;把「需求变更」与「执行参数漂移」在流程上彻底区分开——前者是合法的二次授权,后者是事故。
- 追问 2:网络超时了,用户没收到成功回执,他重试了一次,怎么保证不会发两封?
- 答题切入点:幂等键由参数内容派生并在整个审批周期内固定,重试复用同一键;服务端按键做去重,重复请求返回首次结果。注意幂等键的作用域(按操作类型或按租户隔离)与过期时间要显式设计。
- 追问 3:怎么证明「执行的就是审批的那份」这件事真的成立,而不是口头保证?
- 答题切入点:参数内容哈希 + 审批记录留痕 + 执行侧校验(前后各算一次哈希)+ 审计日志可回放;证明要靠可校验的机制与可追溯的记录,不能靠流程约定。
- 追问 4:批量操作(比如一次发 200 个收件人)时这套还成立吗?
- 答题切入点:成立,但要分级——审批的是批次与规则(哪些收件人、什么模板、什么上限),执行时逐条生成明细并校验明细落在审批规则内;超出规则的条目(如新增收件人)必须单独审批,否则就是「批量形式下的参数漂移」。
延伸阅读:
线上 Agent 出现「指令遵循失败」,是模型能力不够还是工程没做好?怎么归因?
答案
核心结论:
模型参数量只是 Agent 失败归因里的四个候选原因之一。把「换更大的模型」当成默认修复手段,是成本最高、见效最慢的误判。工程上必须先做归因分解,再决定动模型还是动工程:
- 模型能力边界(Model Capability):模型确实「不懂」——需要它做超出训练分布的推理、长程规划或陌生领域判断。这类问题换模型、蒸馏更强模型的数据、或在关键节点升级到大模型评审才有效。
- 上下文污染(Context Pollution):模型「看不到」——关键约束被长上下文稀释、放在「中间遗忘」区间、或检索到的证据是错的。这类问题做上下文裁剪、约束前置、证据重排即可,与参数量无关。
- 任务粒度过大(Task Granularity):模型「接不住」——一次要它规划并执行十几步,任何一步出错整条轨迹就废了。这类问题拆成状态机 / 子任务,让每步只面对一个可判定的局部目标。
- 工具结果不可靠(Tool Reliability):模型「被误导」——工具返回了过期数据、截断结果、或者错误码被当成了正文。这类问题先修工具链,模型再强也救不了错误观测。
关键判据:先看失败样本的共性,再看模型。 如果同类失败集中在某类工具、某个数据源或某段上下文模板上,那是工程问题;只有当失败样本跨工具、跨模板、跨任务类型均匀分布,且集中在需要长程推理的步骤上,才应该怀疑模型能力。
原理解析:
1. 归因分解表:症状 → 候选原因 → 处置动作
| 观察到的症状 | 最可能的归因 | 先做什么(不要一上来换模型) | 换模型有没有用 |
|---|---|---|---|
| 工具选对了、参数格式对、但漏掉了用户明确给出的约束 | 上下文污染 / 约束位置太远 | 把硬约束提到离生成最近的位置、裁剪无关历史、关键约束用结构化字段而非自然语言塞进 prompt | 基本没用 |
| 同一步反复改来改去、越改越乱 | 任务粒度过大 | 拆成状态机,每步产出可校验的中间产物,失败可在步边界重试 | 有一点,但不是主要矛盾 |
| 按工具返回的字段生成了错误结论 | 工具结果不可靠 | 校验工具输出、标注置信度、对截断/过期结果显式降级 | 没用——模型无法分辨它没被告知的事 |
| 跨任务类型都失败,且集中在需要多步推理的环节 | 模型能力边界 | 换更大模型 / 用更强模型的数据蒸馏 / 高风险步骤走大模型评审 | 有用 |
2. 为什么「先换模型」是成本最高的误判
排序逻辑很朴素:先做低代价、高确定性的归因(裁剪上下文、拆任务、修工具),因为这三类的修复成本是工程工时;最后才动模型,因为换模型会同时改变成本、延迟、输出风格与评测基线,等于把一个局部问题变成全局变更。
3. 小模型在 Agent 场景的真实位置
小模型不是「凑合用」,它有明确的擅长区:格式抽取、分类打标、字段归一化这类有确定答案空间的任务,以及作为大模型的评审器 / 路由器 / 子任务执行器。但它不擅长承担开放式决策主链——一旦任务边界不清、观测有歧义,14B 级别的模型就会把不确定性直接编进输出(幻觉),而这类错误在下游会被当成事实继续使用,比直接报错更难发现。
所以正确的问法不是「14B 够不够」,而是「这条任务链路上,哪些环节允许模型有歧义」。凡是允许失败可见(可以回滚、可以校验、可以降级)的环节,小模型完全够用;凡是失败不可见(错误会被静默累积并影响后续决策)的环节,就该上大模型。
规范实现:
下面是这段归因逻辑的可落地面板骨架,用于在评审会上把「到底换不换模型」变成一次可讨论的判定:
type FailureCause =
| 'context-pollution'
| 'task-granularity'
| 'tool-unreliability'
| 'model-capability'
interface FailureSample {
stepIndex: number
toolId: string
templateId: string
violatedConstraint: 'none' | 'explicit-user-constraint' | 'tool-schema' | 'task-goal'
observationTrusted: boolean
}
export function attributeFailures (samples: FailureSample[]): FailureCause {
if (samples.length === 0) return 'model-capability'
// 1. 工具观测不可信 -> 先修工具链,与模型无关
const untrusted = samples.filter(s => !s.observationTrusted).length
if (untrusted / samples.length > 0.3) return 'tool-unreliability'
// 2. 失败集中在少数工具或模板 -> 上下文/工具侧问题
const byTool = groupCount(samples, s => s.toolId)
const byTemplate = groupCount(samples, s => s.templateId)
if (topShare(byTool) > 0.6 || topShare(byTemplate) > 0.6) return 'context-pollution'
// 3. 漏掉的是用户显式给过的约束 -> 模型没看到,不是模型不懂
const missedExplicit = samples.filter(
s => s.violatedConstraint === 'explicit-user-constraint'
).length
if (missedExplicit / samples.length > 0.5) return 'context-pollution'
// 4. 失败分布在多步任务的深水区 -> 任务粒度过大
const deepSteps = samples.filter(s => s.stepIndex >= 4).length
if (deepSteps / samples.length > 0.5) return 'task-granularity'
// 5. 兜底:跨工具跨模板均匀失败,才是能力边界问题
return 'model-capability'
}
export function decideRoute (cause: FailureCause): 'fix-engineering' | 'upgrade-model' {
return cause === 'model-capability' ? 'upgrade-model' : 'fix-engineering'
}
面试官视角:
考察意图: 这题在考工程判断力而非模型知识。候选人的分层能力通常在「知道要加约束」和「知道问题出在约束之前还是之后」之间。满分回答必须体现先归因、再开药的顺序,并且能说清模型能力是四个原因里最靠后考虑的那个。
评分 Rubric:
- 必答:把失败归因拆成四类(模型能力 / 上下文污染 / 任务粒度 / 工具可靠性);说明「换模型是最后手段」;给出可操作的判据(按失败样本的分布特征归因,而不是按个例);指出小模型应放在「失败可见」的环节。
- 加分项:能用失败样本的共性分布(集中在某个工具、某个模板、某个步深)而不是单条 case 来归因;能说清「工具返回错误时模型会自信地沿着错误方向继续」;能提出在同等约束下对比更大模型的消融实验,而不是直接全量切换;能指出评测集要覆盖深水区步骤,否则归因会系统性偏乐观。
- 常见误区:一上来就说「换更大的模型」;把归因当成看几条 bad case 拍脑袋;认为「上下文污染」只能靠换长上下文窗口解决;忘了工具结果本身的可靠性是模型能力的前提而不是并列因素;把「模型不行」写成没有证据的断言。
深度追问链:
- 追问 1:如果归因结论是「上下文污染」,具体怎么改?
- 答题切入点:先量——统计失败样本中关键约束在上下文里的位置分布(是否集中在中段)、以及检索证据的命中率;再改——硬约束从自然语言提到结构化字段并前移、裁剪无关历史、把长约束拆成可校验的清单逐项核对。关键是说清怎么证明改完就好了:用同一批失败样本重跑,看该类失败是否收敛。
- 追问 2:怎么排除「工具结果不可靠」这个干扰项?
- 答题切入点:给工具输出加可信度标记(是否截断、是否过期、是否来自降级路径),让模型在观测里就能看到;统计被标记为不可信的观测在失败样本中的占比。如果占比高,先修工具链——因为此时无论模型多强都是在解一道被污染的题。
- 追问 3:换成大模型之后,怎么保证不是「花钱买了个错觉」?
- 答题切入点:做同批次消融——同一批失败样本、同样的上下文装配与工具链,只换模型;比较该类失败是否真的下降,同时看其它任务的回归(新模型可能带来新的失败模式)。没有消融就只能得到「感觉变好了」这种不可验证的结论。
- 追问 4:小模型放在评审器位置,具体评审什么?
- 答题切入点:评审可判定的形式化问题——输出是否满足 schema、diff 是否只落在允许范围、是否引用了被删除的文件、有没有越过权限边界;不要让小模型评审「方案好不好」这种需要主观判断的任务——小模型在这里的误判率会直接变成漏放行。
延伸阅读:
一个 Agent 任务要串好几次模型调用和工具调用,延迟和成本都压不下来,怎么优化?
答案
核心结论:
Agent 链路的延迟和成本不是一个可优化的量,而是若干段的加权和。第一步永远不是「换小模型」,而是把链路拆成可归因的段——先知道时间花在哪儿,再谈优化手段。四类杠杆按「见效确定性」排序:
- 消除等待(结构层):并行无依赖调用、去掉串行 barrier、把「等模型」改成「等模型的同时做别的事」。这是唯一能把墙钟延迟真正压下来的一类,且不牺牲质量。
- 复用已算过的(缓存层):Prompt Caching / 前缀缓存、检索结果缓存、工具结果缓存、确定性子任务的代码化。这是同时省延迟与省 token 的一类。
- 少算一点(上下文与路由层):上下文预算(裁剪历史、压缩工具定义)、模型路由(简单步骤走小模型)。省的是单次调用的 token 与参数,但要盯质量回归。
- 兜底(降级层):超时降级、部分结果先返回、取消不用的分支。这是保 SLA 的手段,不是降本的手段——降级要用明确的用户可见标注,不能把残缺结果伪装成完整答案。
必须同时说清的两条纪律:
- 口径要分层:报 P50 与 P95 两个分位(并说明各自的业务含义:P50 是典型体验,P95 是尾部体验),只报均值会让少数慢请求消失在平均值里;长链路还要报端到端而不是只报单次模型调用的时延。
- 降本必须配质量回归:任何「换小模型 / 压缩上下文」的改动,都要同时报告同一批任务的质量指标。省了 60% token 但成功率掉了 5 个点,这是失败而不是优化。
原理解析:
1. 先分解:Agent 链路的延迟账本
一次典型的 Agent 任务,墙钟时间由五段构成,只有第三段是「模型生成」:
| 段 | 典型量级 | 主导因素 | 主要优化手段 |
|---|---|---|---|
| ① 输入组装 | 几十~几百 ms | 检索往返、工具表大小、历史裁剪策略 | 缓存检索结果、裁剪历史、把工具表按需发现 |
| ② 排队与 Prefill | 数百 ms~数 s | 并发配额、prompt token 数、缓存命中与否 | 前缀缓存命中、缩短公共前缀、限流下的排队策略 |
| ③ 生成 | 数 s | 首字时延 TTFT + 每 token 解码时延 TBT | 换更小/更快的模型、降低输出长度、约束解码 |
| ④ 工具执行 | 数十 ms~数 s | 外部 API、沙箱冷启动、串行等待 | 并行无依赖工具、沙箱预热、连接复用 |
| ⑤ 回填 | 几十 ms | 序列化、状态写库 | 异步写、批量提交 |
关键结论:只优化第 ③ 段是常见的错误聚焦。 一条 20 秒的链路里,如果 ④ 占了 8 秒,把模型换小一倍省下的时间不到 1 秒——先看哪一段长,再决定动哪里。
2. 并行无依赖调用:唯一不牺牲质量地压墙钟的手段
如果两个工具调用之间没有数据依赖(查 A 仓的库存 和 查 B 仓的库存),串行执行就是纯浪费。判断标准是读写集合不相交,而不是「感觉上能并发」。
interface ToolCall { id: string; tool: string; args: Record<string, unknown> }
interface ToolResult { id: string; output: string; ms: number; failed?: boolean }
// 只有当 calls 之间没有数据依赖时才允许并发;依赖关系必须由调用方显式声明
export async function runTools (calls: ToolCall[], deps: string[][]): Promise<ToolResult[]> {
const levels = topoLevels(calls, deps) // 分层:同层内无依赖,可并发
const results: ToolResult[] = []
for (const level of levels) {
const settled = await Promise.allSettled(
level.map(c => runOne(c))
)
settled.forEach((r, i) => {
results.push(r.status === 'fulfilled'
? r.value
: { id: level[i].id, output: '', ms: 0, failed: true })
})
}
return results
}
注意
Promise.allSettled而不是Promise.all:一个工具失败不能拖垮整层。这里如果用Promise.all,一个工具 reject 会让同层其它已完成的结果全部丢失——这正是「工具失败演变成重试风暴」的常见上游原因。
3. 缓存要分清「缓存什么」和「什么时候会失效」
| 缓存对象 | 缓存键 | 失效条件 | 注意 |
|---|---|---|---|
| Prompt 前缀(KV-Cache / Prompt Caching) | 前缀 token 序列 | 前缀内容变化 | 前缀必须逐字节稳定;工具表顺序、时间戳、随机 id 混进前缀会让命中率归零 |
| 检索结果 | query + 索引版本 | 索引更新、query 改写 | 要把「检索到的新鲜度」作为显式返回字段 |
| 工具执行结果 | 工具名 + 参数哈希 + 外部状态版本 | 外部状态变化 | 写操作不能缓存;读操作也要声明可接受的陈旧度 |
| 模型输出 | 输入完全一致 + 模型版本 + 采样参数 | 采样非确定性 | 采样参数(temperature 等)必须进键,否则「缓存」出来的结果不可复现 |
实践中最常见的缓存失效原因不是 TTL,而是「前缀里混进了不该变的东西」:例如把 Date.now()、随机 request id、动态排序的工具列表写进了 system prompt 的前半段。把稳定内容放前面、易变内容放后面,是前缀缓存命中率的工程前提。
4. 降级不是降本
超时降级、跳过非关键步骤、返回部分结果——这些手段降低的是尾延迟,代价是结果完整性。工程上必须:给降级结果打显式标记(前端要能显示「本次结果可能不完整」)、把降级次数纳入监控、以及禁止在用户不知情时用降级结果驱动写操作。
规范实现:
把上面四类杠杆落成一个可评审的「链路预算」骨架:
interface ChainBudget {
deadlineMs: number // 端到端墙钟预算
maxTokens: number // 全链路 token 预算
perStepMs: Record<string, number> // 各段超时
degradePolicy: 'none' | 'partial' | 'fallback_model'
}
interface Span {
name: string
startMs: number
endMs: number
tokens?: number
cached?: boolean
}
export function analyzeChain (spans: Span[], budget: ChainBudget) {
const total = spans.reduce((a, s) => a + (s.endMs - s.startMs), 0)
const bySegment = new Map<string, number>()
for (const s of spans) {
bySegment.set(s.name, (bySegment.get(s.name) ?? 0) + (s.endMs - s.startMs))
}
// 关键路径占比:找出最值得先动的那一段,而不是凭感觉
const top = [...bySegment.entries()].sort((a, b) => b[1] - a[1])[0]
const overBudget = total > budget.deadlineMs
return {
totalMs: total,
topSegment: top,
topShare: total > 0 ? top[1] / total : 0,
overBudget,
// 只有在「非模型段占主导」时,换模型才是有意义的杠杆
worthUpgradingModel: overBudget && top[0] !== 'generate',
}
}
面试官视角:
考察意图: 这题在考候选人有没有工程化的性能直觉。它区分两类人:只会说「换小模型 / 加缓存 / 并行」名词的,和能指出瓶颈段、给出判定顺序、说明每类手段的代价的。满分回答要体现「先分解、再动手」和「降本必须配质量回归」两条纪律。
评分 Rubric:
- 必答:把链路分解成可归因的段(组装 / 排队预填充 / 生成 / 工具 / 回填);识别并行无依赖调用是唯一不牺牲质量地压墙钟的手段;说明缓存要分对象谈(前缀缓存 vs 检索缓存 vs 工具结果缓存)并说清各自的失效条件;用 P50/P95 而非均值描述体验;说明降本是双刃剑、必须配质量回归。
- 加分项:能指出只报单次模型调用的时延是无效指标(Agent 的体感是端到端);能说出前缀缓存命中率归零的常见原因(前缀里混入时间戳/随机 id/动态排序的工具表);能用工具读集合不相交而不是「感觉能并发」来判定并行安全;能说明
Promise.all与Promise.allSettled在工具编排里的取舍;能提出沙箱冷启动与外部 API 抖动这类环境段而非模型段才是 P95 的真正来源。 - 常见误区:开口就是「换个更小的模型」;把「加并发」当作无条件安全;认为降级可以悄悄进行;只报平均时延;把「压 token 数」等同于「降延迟」;忽略降本后必须跑回归。
深度追问链:
- 追问 1:怎么证明你的并行改造是安全的?
- 答题切入点:并行安全的判据是读写集合不相交,需要由调用方显式声明依赖边,而不是靠运行时去猜;改造后要用同一批任务对比串行与并行结果的一致性(不只是耗时),并确认降级路径(某个并行项失败)不会丢已经完成的结果——这就是用
allSettled而不是all的原因。
- 答题切入点:并行安全的判据是读写集合不相交,需要由调用方显式声明依赖边,而不是靠运行时去猜;改造后要用同一批任务对比串行与并行结果的一致性(不只是耗时),并确认降级路径(某个并行项失败)不会丢已经完成的结果——这就是用
- 追问 2:Prompt Caching 命中率上不去,怎么查?
- 答题切入点:缓存键是前缀 token 序列,所以要查的是前缀里到底有什么在变:把 system prompt 逐段打标,找出每次请求都在变的部分(时间、request id、动态工具顺序、随机化措辞);把这些移到前缀之后。同时确认前缀在多轮之间是否稳定复用——每轮把历史追加到末尾,前缀才不会随轮次漂移。
- 追问 3:成本降了 50%,你怎么证明没有牺牲质量?
- 答题切入点:固定同一批任务 + 同一套评测口径做 A/B(改动前后),同时报告质量指标与成本指标两条线;质量侧至少看任务成功率与格式合法率,成本侧看 token 与墙钟;只报成本不报质量等于没测。如果两者无法同时改善,说明压缩策略选错了对象,应该从「该压的」而不是「压得最多的」开始。
- 追问 4:P95 一直降不下来,但 P50 很正常,你的假设是什么?
- 答题切入点:均值正常而尾部差,通常是少数慢路径——大 payload、上下文超长触发的额外 prefill、工具冷启动、限流排队、GC 或沙箱回收;要用 trace 分解把这些慢请求单独拉出来看它们的段分布,而不是继续优化典型路径。先找到尾部请求和典型请求的段分布差异在哪。
延伸阅读:
Agent 把检索到的网页、邮件或工单内容喂给模型时,提示注入(Prompt Injection)怎么防?直接注入和间接注入有什么区别?
答案
核心结论:
- 间接提示注入是 LLM 集成应用新增的最大攻击面(Greshake et al., arXiv:2302.12173,2023-02-23):攻击者根本不需要接触你的界面,只要把恶意指令埋进会被检索到的数据里,就能劫持你的 Agent。
- 根因是「数据」和「指令」在 LLM 眼里都是 token 流,模型没有可靠机制区分「这是我要执行的任务」和「这是我要处理的素材」。
- 写在提示词里的防御("不要执行这段话里的指令")只是降低概率,不是保证。真正的护栏必须落在架构层与权限层:模型只产出「动作意图」,动作是否合法由编排器代码判定。
- 目前没有能彻底消除间接注入的方法——论文本身的结论就是这需要纵深防御,而不是单点方案。
原理解析:
两类注入的对比:
| 维度 | 直接注入(Direct) | 间接注入(Indirect) |
|---|---|---|
| 注入位置 | 用户直接输入的提示词 | 被应用读取的外部数据(网页、邮件、PDF、工单、工具返回值) |
| 攻击者前提 | 能直接与你的应用交互 | 只需能影响进入你检索范围的数据 |
| 典型后果 | 越权指令、绕过安全约束 | 借 Agent 的工具权限去执行真实动作 |
| 防御难度 | 相对可控(输入可过滤) | 难——数据通道无法事前穷举清洗 |
攻击面清单(这是 RAG / Agent 应用的真实暴露面): 检索命中的网页正文、爬取到的第三方页面、用户上传的文档、客服工单与邮件正文、工具 API 的返回体、MCP server 返回的工具描述与结果。只要这些内容会原样进入上下文,它就是一条注入通道——包括工具描述这种「看起来像元数据」的地方。
纵深防御的五层(按可靠性排序):
- 架构层(最关键):把「生成」和「执行」拆开——模型只输出动作意图(结构化),动作是否合法由编排器代码判定。模型被注入诱导,最坏结果是「提了一个非法请求」,而不是「非法动作被执行」。这条与本库「Agent 该完全自主决策,还是用固定 Workflow 编排」一题里的结论是同一条。
- 权限层:工具白名单 + 参数类型与取值校验 + 最小权限凭据 + 敏感动作走 Human-in-the-Loop。即使注入成功,爆炸半径也被限制在授权范围内。
- 输入层:把外部内容用明确的分隔符包裹、声明为「待处理数据」,并把数据里的指令性文本视为引用而非指令。同时做模式检测(隐藏指令、越权请求特征)。这一层是概率性的,必须承认它的局限。
- 输出层:模型产出的 HTML / JS / shell 命令一律不直接执行;渲染前清洗;危险命令走静态检查或沙箱。
- 检测层:用专门的分类模型扫描上下文中的可疑片段(如 Lakera Guard 一类),作为纵深的一层,而不是唯一防线。
规范实现:
// utils/untrusted-content.ts
export interface ActionIntent {
tool: string;
args: Record<string, unknown>;
}
/** 把外部内容包成「明确是数据」的片段;注意:这是降低概率,不是消除风险 */
export function wrapUntrusted(content: string, source: string): string {
const fence = '=".break"';
return [
'<untrusted_content source="' + source + '">',
content,
'</untrusted_content>',
].join('\n');
}
/** 编排器侧的最终闸门:模型只提议,执行前由代码判定 */
export function authorizeAction(
intent: ActionIntent,
allowlist: Record<string, { requiresApproval: boolean }>
): { allowed: boolean; reason?: string } {
const rule = allowlist[intent.tool];
if (!rule) {
return { allowed: false, reason: '工具不在白名单内:' + intent.tool };
}
if (rule.requiresApproval) {
return { allowed: false, reason: '该工具需要人工审批后才能执行' };
}
// 参数校验同样必须在代码侧做,不能依赖模型自觉
if (typeof intent.args !== 'object' || intent.args === null) {
return { allowed: false, reason: '参数结构非法' };
}
return { allowed: true };
}
面试官视角:
- 必答:能讲清直接 / 间接注入的区别;指出根因是数据与指令不可区分;给出架构层的防御(模型只出意图、代码判合法性)而不只是「加个安全过滤」。
- 加分项:能主动说出「提示词层面的防御是概率性的」以及「目前没有彻底解法」;能列出 RAG 场景里的具体攻击面(网页正文、工具描述、工具返回值)。
- 常见误区:把「输入过滤 + 系统提示词加固」当成完整方案;把间接注入说成「需要用户自己输入才会触发」;把间接注入与「越狱(jailbreak)」混为一谈(前者攻的是数据通道,后者攻的是对齐本身)。
延伸阅读:
Reflexion 的「语言化强化学习」和真正的权重更新有什么区别?Agent 怎么把失败经验变成下一次的成功?
答案
核心结论:
- Reflexion(Shinn et al., arXiv:2303.11366,2023-03-20)不更新任何权重:它让 Agent 用自然语言反思任务反馈,把这段反思写进 episodic memory buffer,下一轮重试时带着这段「经验」一起进上下文。
- 三个角色分工:Actor 执行任务、Evaluator 给反馈信号(标量或自由文本,外部或内部模拟均可)、Self-Reflection 把反馈转写成可复用的策略性经验。
- 它换来的是「用文本换梯度」:不训练、不需要奖励模型,但每轮多花一次 LLM 调用和一段上下文。
- 适用前提是「有便宜可得的评估信号」——编译是否通过、测试是否绿、环境是否胜负。没有可评估反馈的任务上,Reflexion 无从下手。
原理解析:
与真正 RL 的对照(这是本题的核心区分):
| 维度 | 权重更新(RL / PPO) | Reflexion(语言化强化) |
|---|---|---|
| 更新对象 | 模型参数 | 上下文里的自然语言 |
| 信号形式 | 需要可微的稠密奖励 | 标量值或自由文本,不可微也没关系 |
| 生效时机 | 训练结束后,所有任务都受益 | 下一轮立即生效,且只对该任务上下文有效 |
| 成本 | 训练成本高,推理零边际成本 | 训练零成本,每轮多一次调用 |
| 能力上限 | 能把经验泛化进权重 | 泛化有限,换个任务就没了 |
关键区分(面试里最容易被追问的):
- 反思 ≠ 重试:重试只是把同样的输入再跑一遍,不改变下一次模型看到的输入;Reflexion 的产出物会进入下一轮的上下文,这才叫「学习」。
- 反思 ≠ 多 Agent 辩论:辩论式验证是外部评审者(另一个 Agent)给意见;Reflexion 是 Agent 自反思 + 记忆,反馈可以来自外部环境或模型内部模拟。
- 反思 ≠ 失败归因:归因是上线后分析 bad case 的工程手段;Reflexion 是运行时闭环的一部分。
反思文本该写什么: 这是决定成败的细节。写「我失败了」没有价值;有效反思必须包含失败原因 + 下次要改的具体动作,例如「上次把文件名当成了目录路径,导致工具报 ENOENT;下次先调用 list 确认路径」。反思要写成可执行的策略,而不是情绪化的自述。
工程落地形态: 在 Agent Loop 里加一个 reflection buffer——每轮任务结束,由 Evaluator 判定成败;失败时追加一条反思,成功时清空(或标记为已解决)。要注意反思本身会占用上下文,长任务里需要压缩或只保留最近 N 条,否则会挤掉真正的任务上下文。
局限(必须主动说): 只有存在可评估反馈的任务域才适用;反思文本会持续消耗上下文;错误的反思会被固化——模型可能把一个错误归因写进记忆,下一轮照着错方向跑,所以反思文本需要校验或淘汰机制。
规范实现:
// utils/reflection-buffer.ts
export interface ReflectionEntry {
taskId: string;
attempt: number;
failureReason: string;
nextTimeStrategy: string; // 必须写「下次怎么做」,而不是「我错了」
createdAt: number;
}
export class ReflectionBuffer {
private entries: ReflectionEntry[] = [];
constructor(private maxEntries = 5) {}
record(entry: Omit<ReflectionEntry, 'createdAt'>): void {
this.entries.push({ ...entry, createdAt: Date.now() });
if (this.entries.length > this.maxEntries) {
this.entries.shift(); // 只保留最近若干条,避免挤占任务上下文
}
}
/** 构造下一轮的输入片段;成功时返回空串,表示无需携带历史反思 */
buildContext(taskId: string, succeeded: boolean): string {
if (succeeded) {
this.entries = this.entries.filter((e) => e.taskId !== taskId);
return '';
}
const relevant = this.entries.filter((e) => e.taskId === taskId);
if (relevant.length === 0) return '';
const lines = relevant.map(
(e, i) => `${i + 1}. 上次失败原因:${e.failureReason};下次改用:${e.nextTimeStrategy}`
);
return ['【历史反思,请据此调整策略】', ...lines].join('\n');
}
}
面试官视角:
- 必答:说清「不更新权重、只改上下文」这个本质;能讲出 Actor / Evaluator / Self-Reflection 三角色;能举出至少一种可用的评估信号(编译结果、单测、胜负)。
- 加分项:能主动区分「反思 ≠ 重试」「反思 ≠ 外部辩论」;能指出反思文本必须写成可执行策略;能说出「错误反思会被固化」这个风险。
- 常见误区:把 Reflexion 说成「少样本学习的变体」;说「反思就是让模型自己再想一遍」;把它与 PPO 并列称为「两种 RL 算法」。
延伸阅读:
Agent 的推理和行动该交错还是拆开?ReAct 和 Plan-and-Execute 两种架构怎么选?
答案
核心结论:
- 两者的分水岭只有一条:规划与行动是否在时间上解耦。ReAct(arXiv:2210.03629)让模型交错生成「推理轨迹」与「任务动作」,论文原文即写作 "generate both reasoning traces and task-specific actions in an interleaved manner";Plan-and-Execute 则先把完整计划一次列出来,再逐步执行,把规划与执行放在两个角色里。
- ReAct 的官方机制表述是「reason to act」与「act to reason」双向耦合:推理轨迹帮助模型归纳、跟踪、更新行动计划并处理异常;动作则让模型与外部知识库或环境交互,把新信息喂回推理。
- Plan-and-Execute 的官方提示层对应物是 Plan-and-Solve(arXiv:2305.04091):它针对 Zero-shot-CoT 的三类错误做了错误分析——在 46 道 GSM8K 错题上统计出 计算错误 7%、缺步错误 12%、语义误解错误 27%。缺步错误正是「没有先想清楚要做哪几步」造成的,所以 PS 的做法是:先让模型制定一个带步骤的计划并执行该计划,再单独用一个步骤抽取最终答案。
- ReAct 也有官方承认的代价:论文写明交错结构「improves ReAct's groundedness and trustworthiness, such a structural constraint also reduces its flexibility in formulating reasoning steps, leading to more reasoning error rate than CoT」——即交错约束会让推理更死板,推理错误率高于纯 CoT。
- 选型结论:任务结构事先可枚举、要可见进度、要降本 → Plan-and-Execute;步骤分叉大、结果决定下一步、要即时纠错 → ReAct。工程上很少二选一,主流做法是外层 Plan-and-Execute + 每步内层 ReAct 子循环,并在执行结果与计划不符时触发 Replan。
- 这个词的出处要说清:LangChain 官方博客明确写了「Plan-and-Execute agents are heavily inspired by BabyAGI and the recent Plan-and-Solve paper」,且其初始实现把 Executor 做成了一个 Action Agent(即执行器内部自己决定调哪些工具、可能一步也可能多步)——这正是「外层规划 + 内层 ReAct」这一主流工程形态的官方来源,不是社区自创的土办法。
原理解析:
1. 两条轨迹的时序对比
| 维度 | ReAct(交错) | Plan-and-Execute(解耦) |
|---|---|---|
| 模型调用形态 | 每一步一次 LLM 调用,推理与工具调用交替 | 规划一次(可批量出完整步骤),执行每步一次 |
| 计划何时成形 | 边做边修正,计划是隐式的、逐步更新的 | 开局一次性产出有序步骤列表(显式可见) |
| 中途反馈的作用 | 立刻改变下一步决策 | 只能通过 Replanner 显式改写剩余步骤 |
| 模型可分层 | 通常一个模型打通 | 强模型规划 + 弱/快模型执行是天然分工 |
| 失败半径 | 单步失败,只回滚一步 | 初始计划基于错误假设时,后续步骤整条偏掉 |
| 观测成本 | 规划阶段要等计划出完,实时交互场景延迟高 | 规划阶段延迟高 |
| 进度可见性 | 轨迹在运行时才知道 | 步骤列表可先展示给用户确认 |
2. 为什么 Plan-and-Execute 能省钱,以及代价在哪
省钱的机制是模型分层:规划是低频、高价值、需要全局判断的环节,执行是高频、模式相对固定、可用小模型的环节。LangChain 官方博客给出的原话是 Plan-and-Execute「great for more complex long term planning, at the cost of more calls to the language model」;而 Hub 面经聚合站(L2)也印证了这一点——强模型做规划、弱模型做执行是 Plan-and-Execute 最典型的收益点。
代价有三处,必须一起说:
- 初始规划可能不准确:计划基于的是「还没执行任何步骤时的假设」,假设错了整条链都错;
- Planner + Executor 双份调用成本:比单模型多一次规划调用,摊薄后未必更便宜;
- Replan 复杂度:一旦引入重规划,就要额外定义重规划的触发条件(执行失败、观察与计划不符、步数超预算),否则要么从不重规划(错误被放大),要么频繁重规划(震荡)。
3. LangChain 官方实现给出的角色分工
官方实现把 Plan-and-Execute 拆成一个 planner + 一个 executor 两个角色:planner 几乎总是语言模型,用模型的推理能力产出「要做什么」,并在末尾挂一个 output parser 把原始输出解析成步骤字符串列表;executor 在初始实现里就是一个 Action Agent——它接收一个高层目标(单个步骤),自己判断该用哪些工具、可能一步也可能多步完成。这解释了为什么这个架构天然是「外层规划 + 内层 ReAct」:Replanner 的存在则对应 BabyAGI「每轮迭代都重新规划」的做法。
4. 与本库既有考点的边界(别把三道题混为一谈)
| 既有考点 | 它回答的问题 | 本题回答的问题 |
|---|---|---|
| 「Agent 该完全自主决策,还是用固定 Workflow 编排」 | 系统级:决策权归编排器还是归模型 | 模型内部:推理与动作是交错还是先拆开 |
| 「Agent = Model + Harness」的执行循环与停止条件 | 步数预算、指纹去重、无进展检测 | 规划与执行的耦合方式 |
| 「前端如何架构大模型 Agent 交互界面」 | ReAct 轨迹的流式可视化 | 同上 |
规范实现:
// 外层 Plan-and-Execute + 内层 ReAct 子循环 + 显式 Replan 触发
type Step = { id: string; goal: string; status: 'pending' | 'done' | 'failed' };
async function planAndExecute(
planner: (task: string, done: Step[], remaining: Step[]) => Promise<Step[]>,
reactStep: (goal: string, ctx: string[]) => Promise<{ ok: boolean; output: string }>,
task: string
): Promise<string[]> {
const done: Step[] = [];
let remaining: Step[] = await planner(task, done, remaining); // Phase 1: 规划
while (remaining.length > 0) {
const step = remaining[0]!;
// Phase 2: 执行,每一步内部可以是一个 ReAct 子循环(思考 -> 行动 -> 观察)
const r = await reactStep(step.goal, done.map(s => s.goal));
if (r.ok) {
done.push({ ...step, status: 'done' });
remaining.shift();
continue;
}
// Phase 3: 只有「明确的可重规划信号」才重规划,否则失败会被放大
if (shouldReplan(r.output)) {
remaining = await planner(task, done, remaining.slice(1));
} else {
remaining.shift(); // 标记失败后继续,不要在这里死循环
}
}
return done.map(s => s.output);
}
面试官视角:
- 必答:① 说清 ReAct 是推理与动作交错、Plan-and-Execute 是规划与执行解耦;② 能指出交错结构的代价是灵活性下降、推理错误率高于纯 CoT;③ 能说出 Plan-and-Execute 的收益来自模型分层降本与进度可见;④ 讲得出重规划的触发条件。
- 加分项:能报出 Zero-shot-CoT 错误分析的三类占比(计算 7% / 缺步 12% / 语义误解 27%,46 道 GSM8K 错题),并把「缺步」直接对应到「没先规划」;能说出主流工程形态是外层 Plan-and-Execute + 内层 ReAct 子循环,并指出这正是 LangChain 官方实现(executor 本身就是一个 Action Agent);能说出 PS 之所以要单独做答案抽取,是为了避免计划里混入了推理草稿后模型把草稿当答案;能点出 Plan-and-Execute 的溯源是 BabyAGI + Plan-and-Solve 论文。
- 常见误区:把 ReAct 说成「只有工具调用没有规划」;把 Plan-and-Execute 说成「固定 Workflow 编排」(那是编排器写死步骤,而 Plan-and-Execute 的步骤是模型生成的);认为 Plan-and-Execute 一定更省钱(双份调用 + 重规划可能反而更贵);忽略 ReAct 的灵活性代价;把「先规划后执行」当成万能解法而忽略探索性任务无法预先规划。
延伸阅读:
- ReAct: Synergizing Reasoning and Acting in Language Models(arXiv:2210.03629,2022-10-06;交错机制与「结构性约束降低灵活性」的讨论)
- Plan-and-Solve Prompting: Improving Zero-Shot Chain-of-Thought Reasoning by Large Language Models(arXiv:2305.04091,2023-05-06;三类错误分析与 PS / PS+ 两步流程)
- LangChain 官方博客:Plan-and-Execute Agents(该架构的规范出处——溯源 BabyAGI + Plan-and-Solve,planner/executor 角色分工与「executor 是 Action Agent」的初始实现)