前端开发者做多步 Agent:别让 AI 边想边乱跑,用 Plan-Act-Observe 稳住 4 步任务
作者:前端转 AI 深度实践者
【省流助手/核心观点】:多步 Agent 最怕的不是不会调用工具,而是没有计划地乱调用工具。一个更可靠的 Agent 应该遵循 Plan-Act-Observe:先把任务拆成结构化步骤,再执行当前步骤,观察工具结果,把结果写回计划,并根据观察决定下一步。对前端开发者来说,这很像把复杂交互拆成流程节点:每一步有目标、有状态、有输入输出、有失败处理,而不是把所有逻辑塞进一个巨大的
handleUserInput。
第 25 篇我们做了一个最小 Agent Loop。
它已经能完成这样的闭环:
用户输入
-> 模型判断是否调用工具
-> 程序执行工具
-> 工具结果回到上下文
-> 模型生成最终回答
这对简单问题已经够用。
比如:
帮我查一下订单 A1001 到哪了。
Agent 调一次 getOrderStatus,再组织答案,就能完成任务。
但真实用户不会总是问这么简单的问题。
他们更可能问:
帮我查一下订单 A1001 的物流,如果还没送达,再看一下售后政策,告诉我能不能申请延迟补偿。
这个问题突然变成了多步任务:
- 查订单状态。
- 判断是否送达。
- 如果没送达,查延迟补偿政策。
- 结合订单和政策给出建议。
这时候,如果 Agent 只是“边想边跑”,很容易跑偏。
1. 痛点:没有计划的 Agent,就像没看需求就开写代码
前端开发者应该很熟悉这种场景:
需求还没拆清楚,就开始写组件。
写着写着发现:
- 状态放错地方了。
- 接口顺序不对。
- 错误态没处理。
- 中间结果没有保存。
- 最后发现第一步设计就错了。
多步 Agent 也是一样。
如果没有计划,它可能会:
- 先查政策,再查订单,顺序反了。
- 查完订单后忘记判断是否送达。
- 明明订单已签收,还继续查延迟补偿。
- 工具失败了还继续往下走。
- 最终回答时说不清依据。
所以多步 Agent 的第一件事不是“多调几个工具”,而是先把任务拆清楚。
这就是 Plan。
2. 错误做法:让模型每一步临场发挥
一种常见写法是把所有控制权交给模型:
async function runFreeAgent(userInput: string) {
let context = userInput;
for (let i = 0; i < 5; i++) {
const output = await llm.chat(`
你是一个自主 Agent,请根据当前上下文决定下一步。
上下文:
${context}
`);
const toolResult = await runTool(output.toolCall);
context += JSON.stringify(toolResult);
}
return context;
}
这段代码看起来很“自主”,但工程上很难维护:
- 不知道任务一开始被拆成了几步。
- 不知道当前执行到哪一步。
- 不知道某一步失败后该停还是继续。
- 不知道哪些步骤应该被跳过。
- 最终回答很难追溯依据。
多步 Agent 不是越自由越好。
真正可交付的系统,要让每一步都能被看见、被控制、被复盘。
3. 正确做法:先把任务变成结构化计划
Plan-Act-Observe 可以翻译成:
Plan:先拆解任务
Act:执行当前步骤
Observe:记录结果,并影响后续步骤
先定义一个计划步骤:
type StepStatus =
| "pending"
| "running"
| "done"
| "skipped"
| "failed";
type PlanStep = {
id: string;
goal: string;
toolName: string;
args: Record<string, unknown>;
status: StepStatus;
observation?: unknown;
error?: unknown;
skipReason?: string;
};
对刚才那个用户问题,一个最小计划可以长这样:
const plan: PlanStep[] = [
{
id: "step_1",
goal: "查询订单 A1001 的物流状态",
toolName: "getOrderStatus",
args: { orderId: "A1001" },
status: "pending"
},
{
id: "step_2",
goal: "查询延迟送达补偿政策",
toolName: "searchPolicy",
args: { keyword: "延迟补偿" },
status: "pending"
}
];
这份计划有几个好处:
- 每一步目标清楚。
- 每一步要调用哪个工具清楚。
- 每一步参数清楚。
- 当前执行状态清楚。
- 后面可以记录执行结果。
前端同学可以把它类比成多步骤表单:
Step 1:填写基础信息
Step 2:选择配送方式
Step 3:确认订单
Step 4:支付
每一步都有状态:未开始、进行中、完成、失败、跳过。
Agent 计划也是一样。
4. Act:一次只执行当前步骤
执行计划时,不要一次把所有步骤全部跑完。
更稳的方式是一次只拿一个 pending 步骤:
function getNextStep(plan: PlanStep[]) {
return plan.find((step) => step.status === "pending") ?? null;
}
然后执行这个步骤:
type ToolResult =
| {
ok: true;
data: unknown;
}
| {
ok: false;
errorType: string;
message: string;
};
async function act(step: PlanStep): Promise<ToolResult> {
return runTool({
toolName: step.toolName,
args: step.args
});
}
这件事看起来简单,但它让系统变得可控。
因为你随时知道:
- 当前执行到哪一步。
- 调用了哪个工具。
- 用了什么参数。
- 失败时应该标记哪一步。
多步 Agent 最怕“做了很多事,但没人知道它做到哪了”。
5. Observe:工具结果必须写回计划
执行工具之后,要把结果写回计划。
function observe(step: PlanStep, toolResult: ToolResult) {
if (toolResult.ok) {
step.status = "done";
step.observation = toolResult.data;
return;
}
step.status = "failed";
step.error = {
errorType: toolResult.errorType,
message: toolResult.message
};
}
Observe 不是“拿到结果就行”。
Observe 是把结果变成系统状态。只有状态被正确记录,后续步骤才能基于它做判断。
6. 观察结果应该能改变后续计划
计划不是死的。
我们的任务里有一句条件:
如果还没送达,再看一下售后政策。
如果第一步查到订单已签收,第二步其实应该跳过。
type OrderStatus = {
status: "shipping" | "delivered" | "not_found";
eta?: string;
};
function updatePlanAfterObservation(plan: PlanStep[]) {
const orderStep = plan.find((step) => step.id === "step_1");
if (!orderStep || orderStep.status !== "done") return;
const order = orderStep.observation as OrderStatus;
if (order.status === "delivered") {
for (const step of plan.slice(1)) {
if (step.status === "pending") {
step.status = "skipped";
step.skipReason = "订单已签收,不需要继续查询延迟补偿。";
}
}
}
}
这才是 Observe 的价值。
它不是为了记日志而记日志,而是让工具结果影响下一步。
7. 一个最小 Plan Agent 长这样
下面是一版完整但仍然很小的执行器:
type PlanAgentResult = {
ok: boolean;
answer: string;
plan: PlanStep[];
};
async function runPlanAgent(
userInput: string,
maxSteps = 4
): Promise<PlanAgentResult> {
const plan = createPlan(userInput);
let steps = 0;
while (steps < maxSteps) {
steps += 1;
const step = getNextStep(plan);
if (!step) {
return generateFinalAnswer(plan);
}
step.status = "running";
const toolResult = await act(step);
observe(step, toolResult);
if (step.status === "failed") {
return generateFinalAnswer(plan);
}
updatePlanAfterObservation(plan);
}
return {
ok: false,
answer: "超过最大执行步数,Agent 已停止。",
plan
};
}
这段代码没有炫技,但结构非常清楚:
- 先有计划。
- 找到下一步。
- 执行当前动作。
- 观察结果。
- 根据结果更新计划。
- 没有下一步就回答。
如果以后接入真实模型,这个结构仍然成立。
只是 createPlan 可以由模型生成,generateFinalAnswer 也可以由模型根据计划结果生成。
8. 补上 createPlan 和 final answer 的最小实现
学习阶段不一定要一上来就让模型生成计划。
你可以先用规则把流程跑通。
function createPlan(userInput: string): PlanStep[] {
if (!userInput.includes("A1001")) {
return [
{
id: "step_1",
goal: "告知用户当前示例只支持订单 A1001",
toolName: "none",
args: {},
status: "skipped",
skipReason: "当前示例只处理订单 A1001"
}
];
}
return [
{
id: "step_1",
goal: "查询订单 A1001 的物流状态",
toolName: "getOrderStatus",
args: { orderId: "A1001" },
status: "pending"
},
{
id: "step_2",
goal: "查询延迟送达补偿政策",
toolName: "searchPolicy",
args: { keyword: "延迟补偿" },
status: "pending"
}
];
}
最终回答也可以先用规则生成:
function generateFinalAnswer(plan: PlanStep[]): PlanAgentResult {
const failed = plan.find((step) => step.status === "failed");
if (failed) {
return {
ok: false,
answer: `任务在「${failed.goal}」失败:${JSON.stringify(
failed.error
)}`,
plan
};
}
const orderStep = plan.find((step) => step.id === "step_1");
const policyStep = plan.find((step) => step.id === "step_2");
const runnableSteps = plan.filter((step) => step.status !== "skipped");
if (runnableSteps.length === 0) {
return {
ok: false,
answer:
plan
.map((step) => step.skipReason)
.filter(Boolean)
.join("\n") || "当前任务没有可执行步骤。",
plan
};
}
const lines = [
orderStep?.observation
? `订单查询结果:${JSON.stringify(orderStep.observation)}`
: "没有订单查询结果。"
];
if (policyStep?.status === "skipped") {
lines.push(`政策查询已跳过:${policyStep.skipReason}`);
} else if (policyStep?.observation) {
lines.push(`政策查询结果:${JSON.stringify(policyStep.observation)}`);
}
return {
ok: true,
answer: lines.join("\n"),
plan
};
}
这不是最终产品文案,但它能帮你先验证流程。
等 Plan-Act-Observe 跑稳后,再让模型接管计划生成和最终表达,会更容易排查问题。
9. 前端页面怎么展示计划?
多步 Agent 如果只展示最终回答,用户不知道系统做了什么,开发者也很难排查。
可以把计划展示成步骤列表:
function AgentPlanView({ plan }: { plan: PlanStep[] }) {
return (
<ol>
{plan.map((step) => (
<li key={step.id}>
<strong>{step.goal}</strong>
<span>{step.status}</span>
{step.skipReason && <p>{step.skipReason}</p>}
{step.error && <pre>{JSON.stringify(step.error, null, 2)}</pre>}
</li>
))}
</ol>
);
}
这类 UI 在开发环境、运营后台、企业内部工具里很有价值。
因为它能回答几个关键问题:
- Agent 原计划做什么?
- 当前执行到哪一步?
- 哪一步失败了?
- 哪一步被跳过了?
- 最终答案基于哪些观察结果?
10. 生产环境避坑指南
1. 不要让计划无限长
初期计划控制在 2 到 4 步更稳。
计划越长,错误传播越严重,成本和延迟也越高。
2. 关键步骤失败后不要继续编
如果查订单失败,就不要继续基于空数据查补偿政策。
关键步骤失败时,应该停止并说明失败原因。
3. 跳过步骤要写明原因
不要只把状态改成 skipped。
要写清 skipReason,否则排查时不知道是业务条件触发,还是系统漏执行。
4. 高风险步骤必须二次确认
如果计划里包含取消订单、发起退款、发送邮件、删除数据,一定要在执行前确认。
Plan 可以建议高风险步骤,但不能自动越过权限和确认。
5. 每一步都要可回放
记录每一步的 goal、toolName、args、status、observation、error。
否则多步 Agent 一旦出错,就会变成“它好像自己做了很多事,但没人知道具体发生了什么”。
11. 常见误区
误区 1:计划越详细越好
不一定。初期计划 2 到 4 步最好。太长的计划会增加错误传播和维护成本。
误区 2:生成计划后就不能改
计划应该能根据观察结果调整。否则 Observe 就只是记录日志,没有真正参与决策。
误区 3:工具失败后继续执行后续步骤
如果关键步骤失败,应该停下来说明失败原因,而不是继续编一个完整答案。
误区 4:所有计划都必须由模型生成
不需要。学习阶段可以先用规则生成计划。真实项目里,也可以把固定业务流程写死,只让模型处理自然语言理解和答案表达。
12. 给前端开发者的落地清单
如果你要在团队里做多步 Agent,可以从这份清单开始:
- 定义任务类型。
- 为每种任务设计最短计划。
- 每一步都要有
id、goal、status。 - 每一步只调用一个清晰工具。
- 工具结果写入
observation。 - 失败写入
error。 - 可跳过步骤写入
skipped和skipReason。 - 最终答案必须基于 plan 里的观察结果。
- 记录完整执行日志。
- 用测试用例覆盖完成、跳过、失败三种路径。
这份清单看起来像工程流程,而不是 AI 魔法。
这正是重点。
Agent 工程越往后走,越不是让模型随便发挥,而是给模型一个清晰、可观察、可回放的工作台。
结语
多步 Agent 不能边想边乱跑。
它需要先计划,再行动,再观察。
Plan 让任务有结构。
Act 让系统真正执行。
Observe 让结果回到状态,并影响下一步。
这就是 Agent 从“能调工具”走向“能完成任务”的关键一步。
对前端开发者来说,这不是陌生领域。你早就熟悉流程、状态、副作用和错误处理。现在只是把这些工程能力,用在 AI Agent 上。
会写 Prompt 只是开始。
会设计可控的多步执行流程,才是 AI 工程真正的成长信号。