先说我踩的坑
公司客服工单系统接入大模型,第一个版本很天真:让LLM直接返回JSON,指定调哪个工具、传什么参数。上线第一天就出事了。
用户问「我的工单为什么还没处理」,LLM返回:
{"tool": "query_ticket", "params": {"ticket_id": "abc123"}}
看着没问题对吧?但线上跑起来,1000次请求里有183次JSON是坏的:多一个逗号、缺少右括号、被markdown代码块包住、更离谱的是LLM在JSON前面加了一句「好的,我来帮您查询」。18.3%的解析失败率,工单小组差点把我手机打爆。
后来我把架构改成AI Agent模式,试了两种主流方案:ReAct和Plan&Execute。这篇文章把两种方案的实现、压测数据和踩过的坑都写清楚。环境是PHP8.3 + OpenAI gpt-4o-mini,压测用Apache Bench。
问题的本质
LLM直接返回结构化数据这事,能跑通demo,不能上生产。原因有三个:
- 预训练模型的输出分布不是按JSON结构来的,它按token概率来,json语法小概率出错是必然
- 复杂任务需要多步推理+多工具协作,一步生成的JSON根本描述不了过程
- 用户问题里经常有歧义,模型没有机会反问或澄清,一步到位只能靠猜
Agent模式把「一次生成」拆成「循环决策」,模型每步只做一件事:观察当前状态 → 决定下一步动作 → 执行工具 → 再观察。这就是ReAct。Plan&Execute更进一步,先把整个任务拆成计划,再逐步执行。
方案一:ReAct模式
基本原理
ReAct来自Yao et al. 2022年的论文《ReAct: Synergizing Reasoning and Acting in Language Models》。核心是把推理轨迹(Thought)和动作(Action)交替写入prompt:
问题: {用户问题}
可用工具: {工具列表}
思考1: 用户想查工单状态,需要先定位工单ID
动作1: 搜索工单ID [关键词: 4567]
观察1: 找到工单 #T-2024-015,状态为待处理
思考2: 工单存在,状态是待处理,需要查一下超时时间
动作2: 获取工单详情 [工单号: T-2024-015]
观察2: 创建于3天前,SLA时限48小时,已超时
思考3: 已超时,告诉用户并说明预计处理时间
动作3: 最终回答 [工单T-2024-015已超时,预计明天12:00前处理完毕]
每次LLM调用只生成「一个」Thought + Action对,然后执行工具,把结果作为Observation拼回prompt,继续下一轮。循环直到Action类型是「最终回答」。
代码实现
我把它封装成PHP类,核心是循环控制。关键点:维护好对话历史,每轮把Thought/Action/Observation追加进去。
<?php
/**
* 极简ReAct Agent实现
* PHP 8.3 + curl,不依赖Composer包,直接能跑
*/
class ReActAgent
{
private string $apiKey;
private string $model = 'gpt-4o-mini';
private array $tools = [];
private array $messages = [];
public function __construct(string $apiKey, array $tools)
{
$this->apiKey = $apiKey;
$this->tools = $tools;
}
/**
* 运行Agent,返回最终答案
*/
public function run(string $userQuery, int $maxSteps = 8): string
{
$this->messages = [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $userQuery],
];
for ($step = 0; $step < $maxSteps; $step++) {
$response = $this->callLLM();
$parsed = $this->parseAction($response);
if ($parsed['type'] === 'final') {
return $parsed['answer'];
}
if ($parsed['type'] === 'action') {
$observation = $this->executeTool($parsed['tool'], $parsed['params']);
$this->messages[] = [
'role' => 'assistant',
'content' => "Thought: {$parsed['thought']}\nAction: {$parsed['raw_action']}",
];
$this->messages[] = [
'role' => 'user',
'content' => "Observation: $observation",
];
}
}
return '已达最大步数,未能完成任务';
}
private function buildSystemPrompt(): string
{
$toolDesc = [];
foreach ($this->tools as $tool) {
$toolDesc[] = "{$tool['name']}: {$tool['description']} 参数: " . json_encode($tool['params']);
}
return <<<PROMPT
你是工单处理助手。请严格按照以下格式循环推理:
可用工具:
- query_ticket_status: 查询工单状态
参数: {"ticket_id": "字符串"}
- get_ticket_detail: 获取工单详情
参数: {"ticket_id": "字符串"}
- escalate_ticket: 升级工单
参数: {"ticket_id": "字符串", "reason": "字符串"}
必须使用如下格式:
Thought: 你的推理过程
Action: 工具名: {"参数名": "参数值"}
Observation: 工具返回结果(系统填充)
当得到足够信息时,输出:
Thought: 我得到了足够的信息
Final Answer: 给用户的最终答复
PROMPT;
}
private function callLLM(): string
{
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model,
'messages' => $this->messages,
'temperature' => 0,
'max_tokens' => 300,
]),
]);
$result = curl_exec($ch);
curl_close($ch);
$data = json_decode($result, true);
return $data['choices'][0]['message']['content'] ?? '';
}
private function parseAction(string $llmOutput): array
{
// 尝试解析 Final Answer
if (preg_match('/Final Answer:\s*(.+)/s', $llmOutput, $m)) {
return ['type' => 'final', 'answer' => trim($m[1])];
}
// 解析 Thought 和 Action
if (preg_match('/Thought:\s*(.+?)\s*Action:\s*(\w+):\s*(\{.+?\})/s', $llmOutput, $m)) {
$params = json_decode($m[3], true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['type' => 'final', 'answer' => '抱歉,我无法解析您的请求,请重新描述。'];
}
return [
'type' => 'action',
'thought' => $m[1],
'tool' => $m[2],
'raw_action' => trim($m[0]),
'params' => $params,
];
}
return ['type' => 'final', 'answer' => $llmOutput];
}
private function executeTool(string $toolName, array $params): string
{
foreach ($this->tools as $tool) {
if ($tool['name'] === $toolName) {
try {
return call_user_func($tool['callback'], $params);
} catch (\Throwable $e) {
return '工具执行失败: ' . $e->getMessage();
}
}
}
return "未知工具: $toolName";
}
}
// 使用示例
$tools = [
[
'name' => 'query_ticket_status',
'description' => '根据工单ID查询工单状态',
'params' => ['ticket_id' => 'string'],
'callback' => function (array $params) {
// 模拟数据库查询
$tickets = [
'T-2024-015' => ['status' => 'pending', 'created_at' => '2024-03-01 10:00:00'],
'T-2024-016' => ['status' => 'resolved', 'created_at' => '2024-03-01 09:30:00'],
];
$id = $params['ticket_id'] ?? '';
return json_encode($tickets[$id] ?? ['error' => '工单不存在']);
},
],
];
$agent = new ReActAgent(getenv('OPENAI_API_KEY'), $tools);
$answer = $agent->run('我工单T-2024-015怎么还没处理?');
echo $answer;
压测结果
用Apache Bench跑1000个请求,并发50:
ab -n 1000 -c 50 -T 'application/json' -p request.json https://api.example.com/react-agent
# ReAct模式压测结果(1000次请求)
# 成功率: 96.5%
# 平均响应时间: 3.2s
# P95响应时间: 5.8s
# 工具调用成功率: 96.5%
# 平均LLM调用次数: 3.4次
# 最大LLM调用次数: 7次
比裸JSON方式好用,但有个问题:响应慢。每次工具调用都走一次LLM,一个简单问题得3次来回。平均3.2秒,放网页端勉强能接受,但用户反馈「转圈圈」。
方案二:Plan & Execute模式
基本原理
Plan&Execute的思路是两阶段:先规划、后执行。先把用户问题丢给规划器(Planner),让它一次性输出完整步骤列表,然后一个执行器(Executor)按步骤调用工具,不需要每步都问LLM。
用上面那个例子:
规划器输出:
1. 调用 query_ticket_status 查询工单T-2024-015的状态
2. 如果状态是pending,调用 get_ticket_detail 获取创建时间和SLA信息
3. 根据SLA信息计算是否超时
4. 汇总最终答复
执行器按步骤调用工具,收集每步结果。关键优化:局部步骤如果依赖前一步的输出(比如第2步依赖第1步的工单ID),要按顺序执行。不依赖的步骤可以并行。
代码实现
<?php
/**
* 极简 Plan & Execute Agent 实现
* 规划器(Planner) + 执行器(Executor)
*/
class PlanExecuteAgent
{
private string $apiKey;
private array $tools;
private array $messages = [];
public function __construct(string $apiKey, array $tools)
{
$this->apiKey = $apiKey;
$this->tools = $tools;
}
/**
* 运行Agent
*/
public function run(string $userQuery): string
{
// Step 1: 规划
$plan = $this->createPlan($userQuery);
if (isset($plan['error'])) {
return $plan['error'];
}
// Step 2: 执行
$results = [];
foreach ($plan['steps'] as $index => $step) {
$toolName = $step['tool'];
$params = $step['params'];
// 支持变量替换: 前序步骤的结果可以引用
foreach ($params as $key => $value) {
if (is_string($value) && preg_match('/\{\{step(\d+)_(\w+)\}\}/', $value, $m)) {
$prevResult = $results[$m[1] - 1] ?? [];
$params[$key] = $prevResult[$m[2]] ?? $value;
}
}
$result = $this->executeTool($toolName, $params);
$results[$index] = $result;
// Step 3: 检查是否需要重规划
$needsReplan = $this->checkNeedReplan($step, $result);
if ($needsReplan['replan']) {
$plan = $this->replan($userQuery, $plan, $results, $needsReplan['reason']);
// 重新执行剩余步骤(示例简化:重置循环)
$results = [];
foreach ($plan['steps'] as $i => $s) {
$results[$i] = $this->executeTool($s['tool'], $s['params']);
}
break;
}
}
// Step 4: 生成最终回答
return $this->generateFinalAnswer($userQuery, $results);
}
private function createPlan(string $query): array
{
$prompt = <<<PROMPT
你是任务规划器。将用户问题拆解为可执行的步骤列表。
用户问题: $query
可用工具:
- query_ticket_status: 查询工单状态,参数: {"ticket_id": "string"}
- get_ticket_detail: 获取工单详情,参数: {"ticket_id": "string"}
- escalate_ticket: 升级工单,参数: {"ticket_id": "string", "reason": "string"}
- calculate_sla: 计算SLA超时时间,参数: {"created_at": "string", "sla_hours": "int"}
请严格输出JSON数组,不要输出任何其他内容:
[
{"step": 1, "tool": "工具名", "params": {"参数名": "参数值"}, "description": "这一步做什么"},
{"step": 2, "tool": "工具名", "params": {"参数名": "参数值"}, "description": "这一步做什么"}
]
规则:
1. 参数不能凭空编造,如果不知道参数值,用占位符 {{stepN_字段名}} 引用前序步骤输出
2. 步骤数量控制在1-5个
3. 尽量让每一步独立,减少步骤间依赖
PROMPT;
$response = $this->callLLM($prompt);
$plan = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($plan[0])) {
// 解析失败,尝试提取JSON数组
preg_match('/\[.*\]/s', $response, $m);
if (empty($m)) {
return ['error' => '规划器解析失败'];
}
$plan = json_decode($m[0], true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['error' => '规划器输出格式错误'];
}
}
return ['steps' => $plan];
}
private function callLLM(string $prompt): string
{
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => $prompt],
],
'temperature' => 0,
'max_tokens' => 500,
]),
]);
$result = curl_exec($ch);
curl_close($ch);
$data = json_decode($result, true);
return $data['choices'][0]['message']['content'] ?? '';
}
private function executeTool(string $toolName, array $params): array
{
foreach ($this->tools as $tool) {
if ($tool['name'] === $toolName) {
$output = call_user_func($tool['callback'], $params);
return ['output' => $output, 'tool' => $toolName];
}
}
return ['output' => ['error' => "未知工具: $toolName"], 'tool' => $toolName];
}
private function checkNeedReplan(array $step, array $result): array
{
// 如果工具返回错误,需要重规划
if (isset($result['output']['error'])) {
return ['replan' => true, 'reason' => '工具执行失败: ' . $result['output']['error']];
}
// 如果结果中缺少后续步骤需要的字段,需要重规划
$requiredFields = $step['required_outputs'] ?? [];
foreach ($requiredFields as $field) {
if (!isset($result['output'][$field])) {
return ['replan' => true, 'reason' => "缺少字段: $field"];
}
}
return ['replan' => false];
}
private function replan(string $query, array $oldPlan, array $results, string $reason): array
{
// 简化实现:带上下文重新规划
$context = "原计划:\n" . json_encode($oldPlan) . "\n执行结果:\n" . json_encode($results) . "\n失败原因: $reason";
$prompt = "原计划执行失败,请重新规划:\n$context\n\n" . "用户原始问题: $query\n请输出新的步骤列表,保持JSON数组格式。";
$response = $this->callLLM($prompt);
$newPlan = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
preg_match('/\[.*\]/s', $response, $m);
if (!empty($m)) {
$newPlan = json_decode($m[0], true);
}
}
return ['steps' => $newPlan ?? []];
}
private function generateFinalAnswer(string $query, array $results): string
{
$prompt = <<<PROMPT
根据以下工具执行结果,生成给用户的最终答复。
用户问题: $query
执行结果:
PROMPT;
foreach ($results as $i => $result) {
$prompt .= "\n步骤" . ($i + 1) . " ({$result['tool']}): " . json_encode($result['output']);
}
$prompt .= "\n\n请用友好的语气回答用户,不要提到「工具」或「步骤」这些词。";
return $this->callLLM($prompt);
}
}
// 使用示例
$tools = [
[
'name' => 'query_ticket_status',
'callback' => function (array $params) {
$tickets = [
'T-2024-015' => ['status' => 'pending'],
'T-2024-016' => ['status' => 'resolved'],
];
return $tickets[$params['ticket_id']] ?? ['error' => '工单不存在'];
},
],
[
'name' => 'get_ticket_detail',
'callback' => function (array $params) {
$details = [
'T-2024-015' => ['created_at' => '2024-03-01 10:00:00', 'sla_hours' => 48],
];
return $details[$params['ticket_id']] ?? ['error' => '工单不存在'];
},
],
];
$agent = new PlanExecuteAgent(getenv('OPENAI_API_KEY'), $tools);
$answer = $agent->run('我工单T-2024-015怎么还没处理?');
echo $answer;
这里要注意:我用了两种不同的LLM调用方式。ReAct是循环交互,每轮一个请求。Plan&Execute是两段式:一次规划请求 + 多次工具调用(不经过LLM) + 一次总结请求。工具调用不走LLM,这就是关键性能差异。
执行阶段优化:DAG并行
如果步骤之间没有依赖关系,可以并行执行。我用了一个简单的DAG调度器,把独立步骤并发跑。压测数据对比:
# 串行执行
# 4个独立步骤耗时: 320ms
# 并发执行
# 4个独立步骤耗时: 80ms
两种模式对比
| 维度 | ReAct | Plan & Execute |
|---|---|---|
| LLM调用次数 | 3-7次 | 2次(规划+总结) |
| 平均延迟 | 3.2s | 1.1s |
| P95延迟 | 5.8s | 1.9s |
| 工具调用成功率 | 96.5% | 92.3% |
| 重规划/重试率 | 3.5%(工具调用失败后重试) | 18%(规划偏差导致) |
| 多工具协作能力 | 强,每步观察后动态调整 | 中,规划错了很被动 |
| 适合场景 | 需要频繁根据中间结果调整的复杂任务 | 流程固定、步骤清晰的标准化任务 |
数据说明:压测请求是真实的1000条工单查询+处理请求,混合了简单查询(一步)、中等流程(两步)、复杂工单(四步含升级操作)。Plan&Execute的18%重规划率全来自规划器理解错用户意图——比如用户说「我的工单被驳回的」,规划器直接去查状态,没查驳回原因。
混合方案:Plan & Execute + 局部ReAct
真实生产环境我最后用了一个混合方案:Plan&Execute做顶层调度,但在每个步骤内部允许ReAct式的小循环。比如「查工单详情」这步,如果发现工单状态是"被驳回",就进入ReAct循环去查驳回原因、看申诉流程。
/**
* 混合Agent: Plan&Execute做顶层调度
* 每个步骤可以配置是否启用ReAct子循环
*/
class HybridAgent
{
private PlanExecuteAgent $planner;
private ReActAgent $reactor;
public function __construct(string $apiKey, array $tools)
{
$this->planner = new PlanExecuteAgent($apiKey, $tools);
$this->reactor = new ReActAgent($apiKey, $tools);
}
public function run(string $query): string
{
// 先规划
$plan = $this->planner->createPlan($query);
$results = [];
foreach ($plan['steps'] as $index => $step) {
// 判断这步是否需要ReAct子循环
if (in_array($step['tool'], ['query_ticket_status', 'get_ticket_detail'])) {
// 用ReAct处理需要动态判断的步骤
$subQuery = "执行工具 {$step['tool']},参数是 " . json_encode($step['params']);
$results[$index] = [
'output' => $this->reactor->run($subQuery, 3), // 限制最多3步
'tool' => $step['tool'],
];
} else {
// 普通步骤直接执行
$results[$index] = $this->planner->executeTool($step['tool'], $step['params']);
}
}
return $this->planner->generateFinalAnswer($query, $results);
}
}
混合方案的压测数据:
# 混合模式压测结果(1000次请求)
# 成功率: 97.8%
# 平均响应时间: 1.8s
# P95响应时间: 2.6s
# 重规划/重试率: 5.2%
数据背后的原理分析
为什么ReAct更稳但更慢?
ReAct每轮循环都把Observation作为上下文拼回prompt。这有两个维度的影响:
正确性维度:模型每步都能「抬头看路」,发现走错了能及时纠正。比如规划器可能第一步查了状态,第二步发现工单ID不对,ReAct模式下模型会重新提取工单号再查一次。这是ReAct成功率96.5%的原因。
性能维度:OpenAI API按token计费,每轮循环prompt长度都在增长。我统计过:3步的ReAct任务,prompt从初始的1200 tokens涨到第3轮的3100 tokens。这不光是钱的问题,输入token越多,首字延迟越高。3轮循环累计延迟 = 每一轮的输入token处理时间 + 输出token生成时间。这构成了3.2秒平均延迟的大头。
为什么Plan&Execute快但容易规划错?
规划器只看到用户问题,没看到工具执行后的真实返回值。它靠「经验」猜这个工单查询后可能会返回什么字段,然后基于这个猜的字段去设计后续步骤。工具返回的字段跟规划不一致,后续步骤就崩了。
我实际统计了100条Plan&Execute失败案例,分布在三类:
- 规划器编造参数(如用户没提供工单号,它编一个):占42%
- 规划的步骤之间依赖关系错了(第2步用了第3步的输出):占31%
- 工具返回的数据结构跟规划器预期不一致:占27%
避坑指南
这里写我实际踩过的坑,按严重程度排序:
坑1:LLM输出解析必须用「提取」而不是「验证」
第一个版本我验证了json_decode失败就直接返回错误。后来发现LLM返回的内容经常是「好的,我来帮您。`{...}`」这种带前后缀的。改成用正则提取JSON部分再解析,解析失败率从18.3%降到1.2%。
正则提取JSON要小心:`preg_match('/\{.*\}/s', $response, $m)` 遇到嵌套JSON会匹配到第一个`}`就停。用`/\[.*\]/s`或`/\{.*\}/s`加贪婪模式,但如果响应里有一个以上JSON对象会匹配到拼接的脏数据。稳妥做法是先找最后一个`}`或`]`的位置,再往回找对应开头。
坑2:temperature必须设0,不然同样输入两次结果不一样
ReAct模式里,如果temperature不设0,同一个Observation可能让模型走两条不同分支,一次成功一次失败。压测时我做过对比:temperature=0.7时,同一个请求跑两次,一次3步解决,一次7步还没绕出来(触发了max_steps)。
坑3:工具调用的参数校验必须做在Agent里,不能信LLM
LLM生成的参数会超出你定义的schema。比如工单ID我定义的是`T-2024-015`这种格式,LLM可能输出`T2024015`或者`工单T-2024-015`。必须在executeTool入口做严格校验,不合法就返回一个明确的错误Observation给LLM,让它自己修正。不要尝试隐式转换。
坑4:别把所有工具都暴露给LLM
模型会试图用工具做它不该做的事。比如我暴露了一个`delete_ticket`工具,结果有用户问「能帮我删掉这个工单吗」,Agent真的去调用了。生产环境要做两件事:
- 工具按用户角色做权限过滤,普通用户看不到删除类工具
- 危险操作加二次确认,Agent需要调用`confirm_action`工具让用户确认后再执行
坑5:Step的依赖关系别靠LLM自己管理
Plan&Execute里我一开始让LLM自己写步骤之间的依赖,结果它经常写错。后来我人工定义了工具之间的依赖图谱,规划器只负责选择工具和填充参数,依赖关系由代码保证。比如`get_ticket_detail`必须在`query_ticket_status`之后执行,这个逻辑我不让LLM决定。
# 工具依赖配置,人工定义
tool_dependencies:
query_ticket_status:
depends_on: []
get_ticket_detail:
depends_on: [query_ticket_status]
escalate_ticket:
depends_on: [get_ticket_detail]
calculate_sla:
depends_on: [get_ticket_detail]
坑6:线上环境的API key管理用环境变量,别写死在代码里
这个看起来基础,但我确实见过同事把API key直接写在Agent类里提交到Git仓库。用`env('OPENAI_API_KEY')`或者部署平台密钥管理,我习惯在PHP里用getenv(),Laravel项目用config/services.php。
坑7:ReAct模式的max_steps设太小会误杀长任务
我一开始设max_steps=5,结果两步工具调用加一轮总结就要3个循环,偶尔一个工具调用失败了重试一次就到5了,直接返回「已达最大步数」。设到8之后,成功率从89%涨到96.5%。代价是极端情况延迟可能到8秒。按业务需求取舍。
生产建议
给要上Agent的人一个落地清单:
- 先用ReAct跑通业务逻辑,如果发现80%以上的请求工具调用步数固定、不需要动态调整,再换Plan&Execute
- 不要用单一模式部署所有Agent,按任务复杂度选:简单任务(一步工具调用)直接走LLM+工具映射,不要走Agent;中等任务Plan&Execute,复杂任务用混合架构
- Prompt里的工具描述直接影响模型选择哪个工具。描述写得越具体,选对率越高。对比过:「查询工单状态」正确率72%,「根据工单ID精确查询工单当前状态,返回status字段,值为pending/resolved/escalated」正确率91%
- 所有Agent调用建议用Redis做缓存,相同问题+相同工具结果直接返回。我用缓存后重复问询的响应时间从2.5s降到120ms
- 给Agent加监控。每次LLM调用的token数、工具调用的成功率、每步耗时都记录下来,画成图表。没有监控你就不知道线上一半的请求卡在哪一步
代码仓库
完整代码我放在了公司内网GitLab的agent-patterns仓库里,包含:
- src/ReActAgent.php — ReAct完整实现
- src/PlanExecuteAgent.php — Plan&Execute完整实现
- src/HybridAgent.php — 混合实现
- src/ToolRegistry.php — 工具注册和依赖校验
- tests/AgentTest.php — 单元测试(PHPUnit 10.5)
- benchmark/ab_runner.sh — 压测脚本
线上跑了两个月,每天处理约800个工单查询请求。混合方案的P95延迟稳定在2.6秒左右,工具调用成功率和工单处理准确率都达标了。如果你也在做类似的东西,可以按这个思路选型,先跑通再优化。