main: 增强 Agent Run 调度可靠性与幂等性
- 默认切换 AgentProvider 为 HttpAgentProvider,增强网络请求的容错和重试机制 - 优化 Run 逻辑,支持多场景去重与并发保护 - 添加 Redis 发布失败的日志记录以提升问题排查效率 - 扩展 OpenAPI 规范,新增 Error 和 Run 状态相关模型 - 增强测试覆盖,验证调度策略和重复请求的幂等性 - 增加数据库索引以优化查询性能 - 更新所有相关文档和配置文件
This commit is contained in:
@@ -2,15 +2,24 @@
|
||||
|
||||
namespace App\Services\Agent;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class HttpAgentProvider implements AgentProviderInterface
|
||||
{
|
||||
protected string $endpoint;
|
||||
protected int $timeoutSeconds;
|
||||
protected int $connectTimeoutSeconds;
|
||||
protected int $retryTimes;
|
||||
protected int $retryBackoffMs;
|
||||
|
||||
public function __construct(?string $endpoint = null)
|
||||
{
|
||||
$this->endpoint = $endpoint ?? config('services.agent_provider.endpoint', '');
|
||||
$this->endpoint = $endpoint ?? config('agent.provider.endpoint', '');
|
||||
$this->timeoutSeconds = (int) config('agent.provider.timeout_seconds', 30);
|
||||
$this->connectTimeoutSeconds = (int) config('agent.provider.connect_timeout_seconds', 5);
|
||||
$this->retryTimes = (int) config('agent.provider.retry_times', 1);
|
||||
$this->retryBackoffMs = (int) config('agent.provider.retry_backoff_ms', 500);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,14 +38,69 @@ class HttpAgentProvider implements AgentProviderInterface
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
$response = Http::post($this->endpoint, $payload);
|
||||
$attempts = $this->retryTimes + 1;
|
||||
$lastException = null;
|
||||
$lastResponseBody = null;
|
||||
$lastStatus = null;
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Agent provider failed: '.$response->body());
|
||||
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
|
||||
try {
|
||||
$response = Http::connectTimeout($this->connectTimeoutSeconds)
|
||||
->timeout($this->timeoutSeconds)
|
||||
->post($this->endpoint, $payload);
|
||||
|
||||
$lastStatus = $response->status();
|
||||
$lastResponseBody = $response->body();
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
return is_string($data) ? $data : ($data['content'] ?? '');
|
||||
}
|
||||
|
||||
$retryable = $lastStatus === 429 || $lastStatus >= 500;
|
||||
if ($retryable && $attempt < $attempts) {
|
||||
usleep($this->retryBackoffMs * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ProviderException(
|
||||
'HTTP_ERROR',
|
||||
'Agent provider failed',
|
||||
$retryable,
|
||||
$lastStatus,
|
||||
$lastResponseBody
|
||||
);
|
||||
} catch (ConnectionException $exception) {
|
||||
$lastException = $exception;
|
||||
if ($attempt < $attempts) {
|
||||
usleep($this->retryBackoffMs * 1000);
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$lastException = $exception;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$rawMessage = $lastException ? $lastException->getMessage() : $lastResponseBody;
|
||||
|
||||
return is_string($data) ? $data : ($data['content'] ?? '');
|
||||
if ($lastException instanceof ConnectionException) {
|
||||
throw new ProviderException(
|
||||
'CONNECTION_FAILED',
|
||||
'Agent provider connection failed',
|
||||
true,
|
||||
$lastStatus,
|
||||
$rawMessage
|
||||
);
|
||||
}
|
||||
|
||||
throw new ProviderException(
|
||||
'UNKNOWN_ERROR',
|
||||
'Agent provider error',
|
||||
false,
|
||||
$lastStatus,
|
||||
$rawMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
app/Services/Agent/ProviderException.php
Normal file
16
app/Services/Agent/ProviderException.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Agent;
|
||||
|
||||
class ProviderException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $errorCode,
|
||||
string $message,
|
||||
public readonly bool $retryable = false,
|
||||
public readonly ?int $httpStatus = null,
|
||||
public readonly ?string $rawMessage = null,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class CancelChecker
|
||||
->where('session_id', $sessionId)
|
||||
->where('type', 'run.cancel.request')
|
||||
->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM])
|
||||
->where('payload->run_id', $runId)
|
||||
->whereRaw("payload->>'run_id' = ?", [$runId])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,16 @@ class ChatService
|
||||
* - payload: 附加信息(可选)
|
||||
* - reply_to: 被回复的消息 ID(可选)
|
||||
* - dedupe_key: 消息去重键(可选)
|
||||
* @param bool|null $wasDeduped 是否发生了去重(可选,按引用返回)
|
||||
* @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。
|
||||
*/
|
||||
public function appendMessage(array $dto): Message
|
||||
public function appendMessage(array $dto, ?bool &$wasDeduped = null): Message
|
||||
{
|
||||
$messageRef = null;
|
||||
$isNew = false;
|
||||
$wasDeduped = false;
|
||||
|
||||
DB::transaction(function () use ($dto, &$messageRef, &$isNew) {
|
||||
DB::transaction(function () use ($dto, &$messageRef, &$isNew, &$wasDeduped) {
|
||||
/** @var ChatSession $session */
|
||||
$session = ChatSession::query()
|
||||
->whereKey($dto['session_id'])
|
||||
@@ -81,6 +83,7 @@ class ChatService
|
||||
|
||||
if ($existing) {
|
||||
$messageRef = $existing;
|
||||
$wasDeduped = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +115,7 @@ class ChatService
|
||||
|
||||
if ($existing) {
|
||||
$messageRef = $existing;
|
||||
$wasDeduped = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -288,9 +292,11 @@ class ChatService
|
||||
try {
|
||||
Redis::publish($channel, $message->message_id);
|
||||
} catch (\Throwable $e) {
|
||||
if (! app()->runningUnitTests()) {
|
||||
throw $e;
|
||||
}
|
||||
logger()->warning('Redis publish failed', [
|
||||
'session_id' => $message->session_id,
|
||||
'message_id' => $message->message_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,21 +13,24 @@ class OutputSink
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = []): Message
|
||||
public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = [], ?string $dedupeKey = null): Message
|
||||
{
|
||||
$dedupeKey = $dedupeKey ?? "run:{$runId}:agent:message";
|
||||
|
||||
return $this->chatService->appendMessage([
|
||||
'session_id' => $sessionId,
|
||||
'role' => Message::ROLE_AGENT,
|
||||
'type' => 'agent.message',
|
||||
'content' => $content,
|
||||
'payload' => array_merge($meta, ['run_id' => $runId]),
|
||||
'dedupe_key' => $dedupeKey,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = []): Message
|
||||
public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = [], ?bool &$wasDeduped = null): Message
|
||||
{
|
||||
$dedupeKey = $meta['dedupe_key'] ?? null;
|
||||
unset($meta['dedupe_key']);
|
||||
@@ -36,18 +39,19 @@ class OutputSink
|
||||
'session_id' => $sessionId,
|
||||
'role' => Message::ROLE_SYSTEM,
|
||||
'type' => 'run.status',
|
||||
'content' => null,
|
||||
'payload' => array_merge($meta, [
|
||||
'run_id' => $runId,
|
||||
'status' => $status,
|
||||
]),
|
||||
'dedupe_key' => $dedupeKey,
|
||||
]);
|
||||
], $wasDeduped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = []): Message
|
||||
public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = [], ?string $dedupeKey = null): Message
|
||||
{
|
||||
return $this->chatService->appendMessage([
|
||||
'session_id' => $sessionId,
|
||||
@@ -58,6 +62,7 @@ class OutputSink
|
||||
'run_id' => $runId,
|
||||
'message' => $message,
|
||||
]),
|
||||
'dedupe_key' => $dedupeKey,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\AgentRunJob;
|
||||
use App\Models\ChatSession;
|
||||
use App\Models\Message;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RunDispatcher
|
||||
@@ -25,36 +27,50 @@ class RunDispatcher
|
||||
throw (new ModelNotFoundException())->setModel(Message::class, [$triggerMessageId]);
|
||||
}
|
||||
|
||||
$existingForTrigger = Message::query()
|
||||
->where('session_id', $sessionId)
|
||||
->where('type', 'run.status')
|
||||
->where('payload->trigger_message_id', $triggerMessageId)
|
||||
->orderByDesc('seq')
|
||||
->first();
|
||||
$shouldDispatch = false;
|
||||
|
||||
if ($existingForTrigger && ($existingForTrigger->payload['run_id'] ?? null)) {
|
||||
return $existingForTrigger->payload['run_id'];
|
||||
$runId = DB::transaction(function () use ($sessionId, $triggerMessageId, &$shouldDispatch) {
|
||||
ChatSession::query()
|
||||
->whereKey($sessionId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$latestStatus = Message::query()
|
||||
->where('session_id', $sessionId)
|
||||
->where('type', 'run.status')
|
||||
->orderByDesc('seq')
|
||||
->first();
|
||||
|
||||
if ($latestStatus && ($latestStatus->payload['status'] ?? null) === 'RUNNING' && ($latestStatus->payload['run_id'] ?? null)) {
|
||||
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $latestStatus->payload['run_id']]);
|
||||
return $latestStatus->payload['run_id'];
|
||||
}
|
||||
|
||||
$candidateRunId = (string) Str::uuid();
|
||||
$wasDeduped = null;
|
||||
|
||||
$statusMessage = $this->outputSink->appendRunStatus($sessionId, $candidateRunId, 'RUNNING', [
|
||||
'trigger_message_id' => $triggerMessageId,
|
||||
'dedupe_key' => 'run:trigger:'.$triggerMessageId,
|
||||
], $wasDeduped);
|
||||
|
||||
$finalRunId = $statusMessage->payload['run_id'] ?? $candidateRunId;
|
||||
|
||||
if ($wasDeduped) {
|
||||
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $finalRunId]);
|
||||
return $finalRunId;
|
||||
}
|
||||
|
||||
$shouldDispatch = true;
|
||||
|
||||
return $finalRunId;
|
||||
});
|
||||
|
||||
if ($shouldDispatch) {
|
||||
logger('dispatching run', ['sessionId' => $sessionId, 'runId' => $runId]);
|
||||
dispatch(new AgentRunJob($sessionId, $runId));
|
||||
}
|
||||
|
||||
$latestStatus = Message::query()
|
||||
->where('session_id', $sessionId)
|
||||
->where('type', 'run.status')
|
||||
->orderByDesc('seq')
|
||||
->first();
|
||||
|
||||
if ($latestStatus && ($latestStatus->payload['status'] ?? null) === 'RUNNING' && ($latestStatus->payload['run_id'] ?? null)) {
|
||||
return $latestStatus->payload['run_id'];
|
||||
}
|
||||
|
||||
$runId = (string) Str::uuid();
|
||||
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'RUNNING', [
|
||||
'trigger_message_id' => $triggerMessageId,
|
||||
'dedupe_key' => 'run:trigger:'.$triggerMessageId,
|
||||
]);
|
||||
|
||||
dispatch(new AgentRunJob($sessionId, $runId));
|
||||
|
||||
return $runId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace App\Services;
|
||||
|
||||
use App\Services\Agent\AgentProviderInterface;
|
||||
use App\Services\Agent\DummyAgentProvider;
|
||||
use App\Services\Agent\ProviderException;
|
||||
use App\Models\Message;
|
||||
|
||||
class RunLoop
|
||||
{
|
||||
private const TERMINAL_STATUSES = ['DONE', 'FAILED', 'CANCELED'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ContextBuilder $contextBuilder,
|
||||
private readonly AgentProviderInterface $provider,
|
||||
@@ -17,36 +21,106 @@ class RunLoop
|
||||
|
||||
public function run(string $sessionId, string $runId): void
|
||||
{
|
||||
if ($this->isRunTerminal($sessionId, $runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
|
||||
'dedupe_key' => "run:{$runId}:status:CANCELED",
|
||||
]);
|
||||
$this->appendCanceled($sessionId, $runId);
|
||||
return;
|
||||
}
|
||||
|
||||
$context = $this->contextBuilder->build($sessionId, $runId);
|
||||
|
||||
if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
|
||||
'dedupe_key' => "run:{$runId}:status:CANCELED",
|
||||
]);
|
||||
$this->appendCanceled($sessionId, $runId);
|
||||
return;
|
||||
}
|
||||
|
||||
$reply = $this->provider->generate($context);
|
||||
$providerName = $this->resolveProviderName();
|
||||
$startedAt = microtime(true);
|
||||
|
||||
logger('agent provider request', [
|
||||
'sessionId' => $sessionId,
|
||||
'runId' => $runId,
|
||||
'provider' => $providerName,
|
||||
]);
|
||||
|
||||
try {
|
||||
$reply = $this->provider->generate($context);
|
||||
} catch (ProviderException $exception) {
|
||||
$latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
$this->outputSink->appendError($sessionId, $runId, $exception->errorCode, $exception->getMessage(), [
|
||||
'retryable' => $exception->retryable,
|
||||
'http_status' => $exception->httpStatus,
|
||||
'provider' => $providerName,
|
||||
'latency_ms' => $latencyMs,
|
||||
'raw_message' => $exception->rawMessage,
|
||||
], "run:{$runId}:error:provider");
|
||||
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'FAILED', [
|
||||
'error' => $exception->getMessage(),
|
||||
'dedupe_key' => "run:{$runId}:status:FAILED",
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
$latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
logger('agent provider response', [
|
||||
'sessionId' => $sessionId,
|
||||
'runId' => $runId,
|
||||
'provider' => $providerName,
|
||||
'latency_ms' => $latencyMs,
|
||||
]);
|
||||
|
||||
if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
|
||||
'dedupe_key' => "run:{$runId}:status:CANCELED",
|
||||
]);
|
||||
$this->appendCanceled($sessionId, $runId);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->outputSink->appendAgentMessage($sessionId, $runId, $reply, [
|
||||
'provider' => $this->provider instanceof DummyAgentProvider ? 'dummy' : get_class($this->provider),
|
||||
]);
|
||||
'provider' => $providerName,
|
||||
], "run:{$runId}:agent:message");
|
||||
|
||||
if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
|
||||
$this->appendCanceled($sessionId, $runId);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
|
||||
'dedupe_key' => "run:{$runId}:status:DONE",
|
||||
]);
|
||||
}
|
||||
|
||||
private function isRunTerminal(string $sessionId, string $runId): bool
|
||||
{
|
||||
$latestStatus = Message::query()
|
||||
->where('session_id', $sessionId)
|
||||
->where('type', 'run.status')
|
||||
->whereRaw("payload->>'run_id' = ?", [$runId])
|
||||
->orderByDesc('seq')
|
||||
->first();
|
||||
|
||||
$status = $latestStatus ? ($latestStatus->payload['status'] ?? null) : null;
|
||||
|
||||
return in_array($status, self::TERMINAL_STATUSES, true);
|
||||
}
|
||||
|
||||
private function appendCanceled(string $sessionId, string $runId): void
|
||||
{
|
||||
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
|
||||
'dedupe_key' => "run:{$runId}:status:CANCELED",
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveProviderName(): string
|
||||
{
|
||||
if ($this->provider instanceof DummyAgentProvider) {
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
return str_replace("\0", '', get_class($this->provider));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user