# php-ai **Repository Path**: ttweish/php-ai ## Basic Information - **Project Name**: php-ai - **Description**: 一个框架无关的 PHP AI 调用标准库 - **Primary Language**: PHP - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2026-11-01 - **Last Updated**: 2026-11-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # PHP AI 标准库 一个框架无关的 PHP AI 调用标准库,用统一接口屏蔽 **40 个国内外主流 AI 平台**在**鉴权方式、请求协议、返回格式、流式协议**上的差异——国内的通义千问、豆包、文心一言、智谱 GLM、Kimi、腾讯混元、讯飞星火、DeepSeek……,海外的 OpenAI、Claude、Gemini、Grok、Mistral……,以及 OpenRouter、硅基流动等聚合中转与 Ollama 等本地部署。切换平台只需换一个配置项。 除对话能力外,还内置了 **Agent 工具调用循环**、**AI 代码编辑协议**、**带 SSRF 防护的网页抓取工具**与 **Agent 长期记忆**,可直接用于构建后台 AI 助手、代码编辑助手、内容翻译等实际业务。 本文档中的示例均来自真实业务系统(一套 CodeIgniter 3 的建站系统)中的实际用法,非伪代码。 > 🌏 English documentation: [README-en.md](README-en.md) --- ## 特性 - 🔌 **多平台**:内置 40 个平台协议——国内 20 家(通义千问 / 豆包 / 文心一言 / 智谱 GLM / Kimi / 混元 / 星火 / MiniMax / 阶跃星辰 / 零一万物 / 百川 / 商汤 / 360 智脑 / 华为云 MaaS / DeepSeek 等)、海外 12 家(OpenAI / Claude / Gemini / Grok / Mistral / Cohere / Perplexity / Meta Llama / Azure OpenAI 等)、聚合中转 8 家(OpenRouter / 硅基流动 / 魔搭 / Groq / Together / Fireworks / DeepInfra / NVIDIA NIM 等)与本地部署(Ollama / LM Studio / vLLM) - 🎯 **统一接口**:`AI::create()->chat()`,切换平台只需换 `protocol` 或 `model`,业务代码一行不用改 - 🧰 **工具调用全平台统一**:一套工具定义跑 40 个协议,库自动在 OpenAI 的 `tool_calls` 与 Anthropic 的 `tool_use` 块之间翻译,Agent 换平台只改一行配置 - 🛡️ **生产级健壮性**:429 限流与 5xx 自动退避重试(尊重 `Retry-After`)、请求体编码失败快速失败、SSRF 纵深防护 - ⚡ **并发批量**:`chatBatch()` 用 `curl_multi` 并发跑批,实测 10 条提速 3.3 倍,单条失败不拖垮整批 - 🧩 **任意模型 + 任意接口**:模型名不受内置清单限制,可手选协议格式(`protocol`)并指定自定义接口地址(`base_url` / `endpoint`),一套代码同时对接官方 API、第三方中转与自建网关 - 🌊 **流式输出**:一行 `setStream(true)`,自动按 SSE 协议实时吐出数据块;常驻内存框架用 `setStreamCallback()` 接管分片 - 🧰 **Agent 循环**:挂载工具(函数)后自动完成「模型决策 → 执行工具 → 回填结果」多轮循环;`chat()` 连续对话,上下文由库维护 - 🤖 **Claude Code CLI**:直接调用本机 claude 程序(`Ai\Cli\ClaudeCode`),文件读写 / 工具执行 / 会话续接 / 结构化输出,路径自动检测并缓存 - 📊 **CLI 信息查询**:不发起对话即可读取版本、登录态、模型列表、额度用量与限流、生效设置、MCP 状态 - 🔌 **常驻双工会话**:`Ai\Cli\ClaudeCodeSession` 复刻官方 IDE 插件的进程模式,长驻进程多轮对话 + 工具权限实时回调 PHP 决策 + 优雅中断 + 处理过程中继续提需求 - 🖼️ **Agent 多模态**:对话里传图片/PDF,或让 Agent 自己 `read_file` 读图;附件以引用形式进对话历史(多轮与会话恢复后都还在,历史里不存 base64);主模型不支持视觉时自动路由到已配置的视觉模型,看不了图时明确告知而非伪装 - 🗂️ **Agent Tool Registry**:给现有 Controller / Service 加几行 `@agent-tool` 注释,`php-ai index` 扫进 SQLite(FTS5 中文全文检索),Agent 按需搜索、按需加载 Schema;执行回到应用**原有的 Controller 入口权限校验**,不新建第二套权限系统 - 📝 **代码编辑协议**:结构化编辑上下文 + 可校验的编辑动作,支持规划/审核/自动执行三种模式 - 🛡️ **安全抓取**:`HttpFetch` 内置 SSRF、DNS rebinding、内网地址、协议逃逸防护 - 📄 **多模态附件**:图片等附件按各平台格式自动适配 - 🪶 **零硬依赖**:仅需 PHP 与 cURL,可 Composer 安装,也可单文件 autoload 引入 --- ## 环境要求 | 项目 | 要求 | |------|------| | PHP | >= 7.1 | | 扩展 | `ext-curl`、`ext-json`、`ext-mbstring`、`ext-dom`(仅 HTML 转换用到) | | 网络 | 可访问对应平台 API;中国环境通常需配置代理,见 [网络代理](#网络代理) | --- ## 安装 ### Composer ```bash composer require likun-mci/php-ai ``` ```php require __DIR__ . '/vendor/autoload.php'; use Ai\AI; ``` ### 手动引入(不使用 Composer) 库自带 PSR-4 加载器,把整个目录放进项目后引入一次即可: ```php require_once __DIR__ . '/autoload.php'; use Ai\AI; ``` --- ## 快速开始 ```php use Ai\AI; $ai = AI::create([ 'model' => 'gpt-4o', 'api_key' => 'sk-xxxxxxxxxxxxx', ]); $response = $ai->chat('用一句话介绍人工智能'); echo $response->getContent(); // 回复文本 echo $response->tokens(); // 总消耗 tokens ``` 换成国产平台,只需改配置——业务代码一行不用动: ```php $ai = AI::create(['protocol' => 'qwen', 'model' => 'qwen-plus', 'api_key' => 'sk-xxx']); // 通义千问 $ai = AI::create(['protocol' => 'zhipu', 'model' => 'glm-4.6', 'api_key' => 'xxx']); // 智谱 GLM $ai = AI::create(['protocol' => 'doubao', 'model' => 'doubao-seed-1-6', 'api_key' => 'xxx']);// 豆包 $ai = AI::create(['protocol' => 'moonshot', 'model' => 'kimi-latest','api_key' => 'sk-xxx']); // Kimi // 模型名能识别出平台时,protocol 也可以省略 $ai = AI::create(['model' => 'qwen-plus', 'api_key' => 'sk-xxx']); ``` 完整的平台清单见 [支持的平台与模型](#支持的平台与模型),可运行的示例见 `examples_platforms.php`。 `chat()` 接受字符串(自动包装为一条 user 消息)或完整 payload 数组: ```php $response = $ai->chat([ 'messages' => [ ['role' => 'system', 'content' => '你是一个有帮助的助手'], ['role' => 'user', 'content' => '介绍一下人工智能'], ['role' => 'assistant', 'content' => '人工智能是……'], ['role' => 'user', 'content' => '再简短一点'], ], 'temperature' => 0.7, 'max_tokens' => 1000, ]); ``` > **多轮对话**:默认不保存历史(`messages` 由业务层完整传入)。若想让库自动维护上下文,设 `rounds` 即可,见 [多轮对话上下文](#多轮对话上下文)。 --- ## 支持的平台与模型 ### 平台一览 `protocol` 传下表中的取值,`api_key` 传该平台控制台的密钥,其余写法所有平台完全一致: ```php // 换平台只改这两行,业务代码不动 $ai = AI::create([ 'protocol' => 'qwen', // 见下表 'model' => 'qwen-plus', 'api_key' => 'sk-xxx', ]); echo $ai->chat('你好')->getContent(); ``` #### 中国大陆主流平台 | 平台 | `protocol` 取值 | 默认端点 | 模型名自动识别 | |------|----------------|---------|---------------| | DeepSeek 深度求索 | `deepseek`
别名 `deepseek-ai` | `api.deepseek.com/v1/chat/completions` | `deepseek-*` | | 阿里云百炼(通义千问) | `qwen`
别名 `dashscope` / `bailian` / `tongyi` / `aliyun` | `dashscope.aliyuncs.com/compatible-mode/v1/chat/completions` | `qwen*`、`qwq*` | | 阿里云百炼(通义千问) | `qwen-anthropic`
别名 `qwen-claude` / `dashscope-anthropic` | `dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy/v1/messages` | — | | 火山方舟(豆包) | `doubao`
别名 `ark` / `volcengine` / `volces` / `volc` | `ark.cn-beijing.volces.com/api/v3/chat/completions` | `doubao-*` | | 百度千帆(文心一言) | `ernie`
别名 `qianfan` / `baidu` / `wenxin` / `yiyan` | `qianfan.baidubce.com/v2/chat/completions` | `ernie-*` | | 智谱 AI(GLM) | `zhipu`
别名 `glm` / `bigmodel` / `chatglm` / `zhipuai` | `open.bigmodel.cn/api/paas/v4/chat/completions` | `glm-*`、`cogview-*` | | 智谱 AI(GLM) | `zhipu-anthropic`
别名 `glm-anthropic` / `zhipu-claude` | `open.bigmodel.cn/api/anthropic/v1/messages` | — | | 月之暗面(Kimi) | `moonshot`
别名 `kimi` / `yuezhianmian` | `api.moonshot.cn/v1/chat/completions` | `kimi-*`、`moonshot-*` | | 月之暗面(Kimi) | `moonshot-anthropic`
别名 `kimi-anthropic` / `moonshot-claude` | `api.moonshot.cn/anthropic/v1/messages` | — | | 腾讯混元 | `hunyuan`
别名 `tencent` / `tencent-hunyuan` | `api.hunyuan.cloud.tencent.com/v1/chat/completions` | `hunyuan-*` | | 讯飞星火 | `spark`
别名 `xunfei` / `iflytek` / `xfyun` / `xinghuo` | `spark-api-open.xf-yun.com/v1/chat/completions` | `spark*`、`4.0Ultra` | | MiniMax(稀宇科技) | `minimax`
别名 `xiyu` / `minimaxi` | `api.minimaxi.com/v1/text/chatcompletion_v2` | `MiniMax-*`、`abab*` | | 阶跃星辰(Step) | `stepfun`
别名 `step` / `jieyue` | `api.stepfun.com/v1/chat/completions` | `step-*` | | 零一万物(Yi) | `yi`
别名 `lingyiwanwu` / `01ai` / `01-ai` / `zeroone` | `api.lingyiwanwu.com/v1/chat/completions` | `yi-*` | | 百川智能 | `baichuan`
别名 `baichuan-inc` | `api.baichuan-ai.com/v1/chat/completions` | `Baichuan*` | | 商汤日日新(SenseNova) | `sensenova`
别名 `sensetime` / `sensechat` / `shangtang` | `api.sensenova.cn/compatible-mode/v1/chat/completions` | `SenseChat-*` | | 360 智脑 | `zhinao`
别名 `360` / `qihoo` / `360ai` | `api.360.cn/v1/chat/completions` | `360gpt*` | | 华为云 ModelArts(盘古 / MaaS) | `modelarts`
别名 `huawei` / `maas` / `pangu` | `api.modelarts-maas.com/v1/chat/completions` | — | #### 海外主流平台 | 平台 | `protocol` 取值 | 默认端点 | 模型名自动识别 | |------|----------------|---------|---------------| | OpenAI | `openai`
别名 `oai` / `openai-compatible` / `compatible` / `chat_completions` | `api.openai.com/v1/chat/completions` | `gpt-*`、`o3` | | Anthropic Claude | `claude`
别名 `anthropic` / `claude-messages` / `messages` | `api.anthropic.com/v1/messages` | `claude-*` | | Google Gemini | `gemini`
别名 `google` | `generativelanguage.googleapis.com/v1beta/openai/chat/completions` | `gemini-*` | | Z.ai(智谱国际站) | `zai`
别名 `z-ai` / `zhipu-global` | `api.z.ai/api/paas/v4/chat/completions` | — | | xAI(Grok) | `grok`
别名 `xai` / `x-ai` / `x.ai` | `api.x.ai/v1/chat/completions` | `grok-*` | | Mistral AI | `mistral`
别名 `mistralai` | `api.mistral.ai/v1/chat/completions` | `mistral-*`、`codestral-*` | | Meta(Llama API) | `llama`
别名 `meta` / `meta-llama` | `api.llama.com/compat/v1/chat/completions` | — | | Cohere | `cohere`
别名 `command` | `api.cohere.ai/compatibility/v1/chat/completions` | `command-*` | | Perplexity | `perplexity`
别名 `pplx` / `sonar` | `api.perplexity.ai/chat/completions` | `sonar*` | | Azure OpenAI | `azure`
别名 `azure-openai` / `azureopenai` | `需自填 `base_url`` | — | #### 聚合中转平台 | 平台 | `protocol` 取值 | 默认端点 | 模型名自动识别 | |------|----------------|---------|---------------| | OpenRouter | `openrouter`
别名 `or` / `open-router` / `open_router` | `openrouter.ai/api/v1/chat/completions` | — | | 硅基流动(SiliconCloud) | `siliconflow`
别名 `silicon` / `siliconcloud` / `guiji` | `api.siliconflow.cn/v1/chat/completions` | — | | 魔搭社区(ModelScope) | `modelscope`
别名 `moda` / `damo` | `api-inference.modelscope.cn/v1/chat/completions` | — | | Groq | `groq`
别名 `groqcloud` | `api.groq.com/openai/v1/chat/completions` | — | | Together AI | `together`
别名 `togetherai` / `together-ai` | `api.together.xyz/v1/chat/completions` | — | | Fireworks AI | `fireworks`
别名 `fireworksai` | `api.fireworks.ai/inference/v1/chat/completions` | — | | DeepInfra | `deepinfra` | `api.deepinfra.com/v1/openai/chat/completions` | — | | Cerebras | `cerebras` | `api.cerebras.ai/v1/chat/completions` | — | | NVIDIA NIM | `nvidia`
别名 `nim` / `nvidia-nim` / `build-nvidia` | `integrate.api.nvidia.com/v1/chat/completions` | — | #### 本地 / 自建部署 | 平台 | `protocol` 取值 | 默认端点 | 模型名自动识别 | |------|----------------|---------|---------------| | Ollama(本地) | `ollama` | `localhost:11434/v1/chat/completions` | — | | LM Studio(本地) | `lmstudio`
别名 `lm-studio` | `localhost:1234/v1/chat/completions` | — | | vLLM(自建) | `vllm`
别名 `sglang` / `xinference` | `localhost:8000/v1/chat/completions` | — | > 表格由 `$ai->listProtocols()` / `listProtocolGroups()` 提供程序化版本,可直接渲染后台下拉框; > `$ai->listKnownModels('qwen')` 返回该平台的常用模型清单(不发请求)。 > **别名**只是同一协议的另一种写法,效果完全相同(`protocol => 'kimi'` 等价于 `protocol => 'moonshot'`)。 ### 内置模型标识(快捷方式) 下列**模型标识**可直接传给 `model`,无需 `protocol`,库会解析出平台、协议与端点: | 平台 | 模型标识 | 实际协议 | 端点 | |------|---------|---------|------| | OpenAI | `gpt-4.1` | OpenAI | api.openai.com | | OpenAI | `gpt-4o` | OpenAI | api.openai.com | | Claude | `claude-3-opus` | Claude | api.anthropic.com | | Gemini | `gemini-2.5-pro` | Gemini(OpenAI 兼容端点) | generativelanguage.googleapis.com | | DeepSeek | `deepseek-chat` | OpenAI | api.deepseek.com | | DeepSeek | `deepseek-reasoner` | OpenAI | api.deepseek.com | | DeepSeek | `deepseek-v4-pro` | OpenAI | api.deepseek.com | | DeepSeek | `deepseek-v4-flash` | OpenAI | api.deepseek.com | | DeepSeek | `deepseek-anthropic` | **Claude** | api.deepseek.com/anthropic | | OpenRouter | `openai/gpt-4o` 等完整标识 | **OpenRouter**(OpenAI 兼容) | openrouter.ai/api | `deepseek-anthropic` 是 DeepSeek 的 Anthropic 兼容端点,用 Claude 协议通信——**需要工具调用(Agent)时用它,可以用 DeepSeek 的价格跑 Anthropic 的 tools 协议**。 ### 表外的模型也能直接用 上表只是「开箱即用的快捷标识」,**`model` 并不限于表内的值**: - 官方新模型(如 `claude-opus-5`、`gpt-5.1`、`qwen3-max`、`glm-4.6`、`kimi-k2-turbo-preview`):库按模型名识别出协议家族与官方端点,直接可用,不必等库更新; - 被多家平台托管的开源模型(如 `llama3`、`mixtral`)或第三方中转/自建网关的模型:加上 `protocol` 或 `base_url` 即可。 ```php // 官方新模型:识别出 claude 家族,自动使用 api.anthropic.com/v1/messages $ai = AI::create(['model' => 'claude-opus-5', 'api_key' => 'sk-ant-xxx']); // 国产新模型:识别出 zhipu 家族,自动使用 open.bigmodel.cn/api/paas/v4/chat/completions $ai = AI::create(['model' => 'glm-4.6', 'api_key' => 'xxx']); // 开源模型 / 第三方接口:任意模型名 + 手选协议 + 自定义地址 $ai = AI::create([ 'model' => 'llama3', 'protocol' => 'openai', // 手选协议格式 'base_url' => 'http://10.0.0.9:11434/v1', // 自定义接口地址 ]); ``` > 模型名无法归属官方平台、又没给 `base_url` / `endpoint` 时,请求前会抛 `ConfigException`,而不是把 Key 发到不相干的官方域名。 `protocol` 除了上面平台表里的取值,还可以传实现了 `Ai\Contracts\ProtocolInterface` 的**自定义协议类名**(见「扩展开发」)。 不传 `protocol` 时按模型名推断(见平台表「模型名自动识别」列),也识别 `厂商/模型` 写法(如 `qwen/qwen-max`)。推断不出时按 `openai` 协议处理,此时必须给 `base_url` 或 `endpoint`。 > **只对厂商自有模型名做推断**。`llama3`、`mixtral` 这类被多家平台托管的开源模型名不参与推断(无法判断你想连哪一家),需要显式给 `protocol` 或 `base_url`。 ### 协议差异(重要) 各平台协议对 payload 键的支持并不一致,写业务代码前需要知道: | payload 键 | OpenAI 协议 | Claude 协议 | Gemini 协议 | |-----------|------------|------------|------------| | `messages` | ✅ | ✅(`role: system` 会被丢弃) | ✅ | | `system`(顶层) | ❌ 忽略 | ✅ | ❌ 忽略 | | `tools` / `tool_choice` | ❌ 忽略 | ✅ | ❌ 忽略 | | `temperature` / `max_tokens` | ✅ | ✅ | ✅ | | `stream` | ✅(自动附带 usage 统计) | ✅ | ✅ | 结论: - **OpenAI / Gemini** 的系统提示词要写进 `messages` 的 `role: system`; - **Claude** 的系统提示词要写在顶层 `system` 键; - **Agent / 工具调用只能用 Claude 协议**。除 Anthropic 官方外,以下国产平台提供了 Anthropic 兼容端点,可以用国产模型的价格跑 Agent: | `protocol` | 平台 | 鉴权用的密钥 | |-----------|------|------------| | `deepseek-anthropic`(模型标识) | DeepSeek | `deepseek__api_key` | | `zhipu-anthropic` | 智谱 GLM | `zhipu__api_key` | | `moonshot-anthropic` | 月之暗面 Kimi | `moonshot__api_key` | | `qwen-anthropic` | 阿里云百炼 | `qwen__api_key` | ```php // 用 GLM-4.6 跑工具调用:协议是 Claude 的,价格与密钥是智谱的 $ai = AI::create([ 'protocol' => 'zhipu-anthropic', 'model' => 'glm-4.6', 'api_key' => $config['zhipu__api_key'], ]); ``` ### 运行时查询平台与模型 ```php $ai = new AI(); // —— 不发请求的元信息查询(适合渲染后台下拉框) —— $ai->listPlatforms(); // 37 个平台:['deepseek'=>'DeepSeek 深度求索','qwen'=>'阿里云百炼(通义千问)',...] // 键即业务层约定的密钥前缀 {平台}__api_key $ai->listProtocols(); // 40 个协议:['openai'=>'OpenAI 兼容(Chat Completions)','qwen'=>'阿里云百炼 / 通义千问(OpenAI 兼容)',...] $ai->listProtocolGroups(); // 同上,但按「中国大陆 / 海外主流 / 聚合中转 / 本地部署」分组,可直接渲染 optgroup $ai->listKnownModels('qwen');// 该平台的常用模型:['qwen3-max'=>'通义千问 3 Max','qwen-max'=>'通义千问 Max',...] $ai->platformOfModel('qwen-max'); // 'qwen'(设置模型前即可安全调用,无法归属官方平台时返回 custom) // —— 设置模型后的当前状态 —— $ai->setConfig(['model' => 'glm-4.6', 'api_key' => 'xxx']); $ai->getPlatform(); // 'zhipu' $ai->getProtocolKey(); // 'zhipu',当前实际使用的协议 $ai->resolveEndpoint(); // 'https://open.bigmodel.cn/api/paas/v4/chat/completions',当前实际请求端点 $ai->listKnownModels(); // 不传参数时取当前协议的内置清单 // —— 实时调用平台接口拉取模型 —— $ai->listModels(); // 端点跟随 base_url / endpoint 走,接第三方网关时列的就是网关的模型 // 拉取失败或平台无此接口时:若请求的是官方域名,回退到内置常用清单; // 接的是第三方网关则返回 null(避免把官方清单误当成网关的能力) $ai->listModels(true); // 传入 true 返回完整模型数据(含 id / created / owned_by / pricing 等) // 适用于 OpenRouter、硅基流动等需要展示模型价格/能力标签的场景 ``` **真实用例**——按平台分组拉取模型列表并本地缓存一周,供后台下拉框渲染: ```php $platforms = $this->ai->listPlatforms(); $result = []; foreach ($platforms as $platform => $platformName) { $apiKey = (string)($this->siteConfig["{$platform}__api_key"] ?? ''); if (empty($apiKey)) continue; // 未配置 Key 的平台直接跳过 try { $ai = new AI(); // 该平台任一模型即可(用于确定协议),直接取库内置清单的第一个 $known = $ai->listKnownModels(\Ai\Helpers\Protocols::platformProtocols($platform)[0] ?? ''); $ai->setConfig([ 'model' => (string)array_key_first($known), 'api_key' => $apiKey, ]); $models = $ai->listModels(); if (is_array($models) && $models) $result[$platform] = $models; } catch (\Exception $e) { // 单平台失败不影响其它平台 } } ``` --- ## 配置项 ```php $ai->setConfig([ 'model' => 'gpt-4o', // 模型标识(必填),内置标识或任意自定义模型名 'api_key' => 'sk-xxx', // API 密钥(自建/内网接口可不填) 'protocol' => '', // 手选协议格式:见「平台一览」,或自定义协议类名 'tools' => [], // 工具定义(统一格式,见「Agent:工具调用循环」) 'tool_choice' => null, // auto / any / none / ['type'=>'tool','name'=>'x'] 'base_url' => '', // 接口根地址,与协议官方路径智能拼接,见「自定义接口地址」 'endpoint' => '', // 完整对话端点,原样使用,优先级高于 base_url 'endpoint_models' => '', // 完整模型列表端点(仅 listModels 生效) 'platform' => '', // 平台名,仅供业务层标识,默认由模型名/协议决定 'headers' => [], // 追加/覆盖请求头,值为 null 表示删除协议默认头 'extra_body' => [], // 追加到请求体的私有参数 'search' => false, // 联网搜索,true 或细化数组,见「联网搜索」 'max_tokens' => 1024 * 64, // 最大输出 tokens 'max_completion_tokens' => 16384, // 仅 OpenAI o1/o3 系列,覆盖 max_tokens 'temperature' => 0.7, // 温度 'organization' => 'org-xxx', // 仅 OpenAI 企业账号 'project_id' => 'proj_xxx', // 仅 OpenAI 企业账号 ]); ``` `setConfig()` 是**增量合并**,可以分多次调用;每次传入 `model` 都会重建模型与协议实例。`base_url`、`protocol` 等既可以和 `model` 一起传,也可以在设置模型之后再补。 生成参数(`max_tokens`、`max_completion_tokens`、`temperature`、`top_p`、`top_k`、`stop`、`presence_penalty`、`frequency_penalty`、`seed`、`response_format`、`system`、`tools`、`tool_choice`、`reasoning_effort`、`thinking`)写在 `setConfig()` 里对所有请求生效,单次 `chat()` 的 payload 优先级更高;连接信息(`api_key`、`base_url` 等)不会进入请求体。 接口有私有参数时用 `extra_body`(直接并入请求体),需要特殊鉴权头时用 `headers`: ```php $ai->setConfig([ 'headers' => [ 'Authorization' => null, // 删掉协议默认写入的 Bearer 头 'X-Api-Token' => 'abc123', // 换成对方要求的鉴权头 ], 'extra_body' => ['enable_thinking' => false, 'safe_mode' => 1], ]); ``` 链式接口: ```php $ai->setModel('claude-3-opus') // 单独切换模型 ->setTimeout(300) // 超时(秒),长文本生成务必调大 ->setConnectTimeout(30) // 连接超时(秒),独立于总超时 ->setUserAgent('MyApp/1.0') // 自定义 User-Agent(默认不发送) ->setSslVerify(false) // 禁用 SSL 校验(仅调试/内网自签) ->setProxy('socks5h://127.0.0.1:1080') ->setStream(true) ->setStreamCallback($fn) // 流式分片交给回调(常驻内存框架必用,见「流式输出」章节) ->setAttachments([$file]) ->chat($prompt); ``` 调试时可用 `$ai->getLastInfo()` 获取最近一次请求的 cURL 信息(http_code、总耗时等)。 ### 网络代理 支持 `http://`、`https://`、`socks5://`、`socks5h://`(DNS 也走代理)、`socks4://`、`socks4a://`: ```php if (!empty($config['PROXY_SOCKS5'])) { $ai->setProxy($config['PROXY_SOCKS5']); } elseif (!empty($config['PROXY_HTTP'])) { $ai->setProxy($config['PROXY_HTTP']); } ``` ### 自定义接口地址(第三方转发 / 中转 / 自建网关) 默认端点是各平台的官方地址。要接入第三方转发、中转或自建网关,有两种配置方式,优先级从高到低: **1. `base_url` —— 接口根地址,与协议官方路径智能拼接(最常用)** 只要给出根地址即可,库会补上协议对应的路径;根地址自带的路径前缀会保留,与官方路径重叠的片段自动去重: ```php $ai = AI::create([ 'model' => 'deepseek-chat', 'api_key' => 'sk-xxx', 'base_url' => 'https://proxy.example.com', // 或带端口 http://127.0.0.1:8080 ]); // 实际请求 => https://proxy.example.com/v1/chat/completions ``` | `base_url` | 协议路径 | 实际端点 | |-----------|---------|---------| | `https://proxy.com` | `/v1/chat/completions` | `https://proxy.com/v1/chat/completions` | | `https://proxy.com/v1` | `/v1/chat/completions` | `https://proxy.com/v1/chat/completions`(重叠段去重) | | `https://proxy.com/openai` | `/v1/chat/completions` | `https://proxy.com/openai/v1/chat/completions` | | `https://proxy.com/v1/chat/completions` | `/v1/chat/completions` | 原样(已是完整端点) | | `127.0.0.1:8080` | `/v1/chat/completions` | `https://127.0.0.1:8080/v1/chat/completions`(缺 scheme 按 https) | **2. `endpoint` —— 完整端点覆盖,原样使用(最灵活)** 当接口路径结构与官方完全不同时使用,直接给出完整 URL: ```php $ai = AI::create([ 'model' => 'deepseek-chat', 'api_key' => 'sk-xxx', 'endpoint' => 'https://proxy.example.com/openai/deepseek/chat', // 原样使用 ]); ``` `endpoint` 优先级高于 `base_url`;两者都不设置时使用官方默认端点,完全向后兼容。 --- ### OpenRouter 聚合中转 [OpenRouter](https://openrouter.ai) 是一个 AI 模型聚合平台,通过统一的 OpenAI 兼容接口访问 OpenAI、Claude、Gemini、DeepSeek、Llama 等多种模型。库已内置 `openrouter` 协议,**开箱即用**: ```php // 方式一(推荐):手选 protocol = openrouter,自动使用 openrouter.ai/api $ai = AI::create([ 'model' => 'openai/gpt-4o', // OpenRouter 上的完整模型标识 'protocol' => 'openrouter', 'api_key' => 'sk-or-v1-xxxxxxxxx', // OpenRouter API Key 'referer' => 'https://myapp.com', // 可选,来源标识(OpenRouter 后台查看) 'title' => 'MyApp', // 可选,应用名称 ]); // 方式二:用 base_url 配置(不依赖 protocol=openrouter) $ai = AI::create([ 'model' => 'anthropic/claude-sonnet-4-20250514', 'base_url' => 'https://openrouter.ai/api', 'api_key' => 'sk-or-v1-xxxxxxxxx', ]); ``` OpenRouter 上的模型名使用 `厂商/模型` 格式,协议推断规则会自动提取厂商前缀: - `openai/gpt-4o` → 协议推断为 `openai`,自动使用 OpenAI 协议格式 - `anthropic/claude-sonnet-4-20250514` → 协议推断为 `claude`(但 OpenRouter 接口仍以 OpenAI 兼容格式应答,**协议以 `protocol` 配置为准**) - `deepseek/deepseek-chat` → 协议推断为 `deepseek` - `google/gemini-2.5-pro-exp-03-25` → 协议推断为 `gemini` > **OpenRouter 返回的 usage 字段是 OpenRouter 统计而非原始模型统计**(可能包含缓存命中标记等扩展字段),`$response->getUsage()` 原样透传。 **通过 OpenRouter 查看实时模型状态:** ```php $ai = AI::create([ 'protocol' => 'openrouter', 'api_key' => 'sk-or-v1-xxx', ]); $models = $ai->setModel('openai/gpt-4o')->listModels(true); // 返回含 id、pricing、context_length 等的完整模型信息 ``` --- ### 其他常见 AI 中转/聚合服务 OpenRouter、硅基流动、魔搭、Groq、Together、Fireworks、DeepInfra、NVIDIA NIM 已内置为协议(见「平台一览」),直接 `protocol` 指定即可。其余中转站与自建网关走 OpenAI 兼容协议(`protocol=openai`),配置 `base_url` 或 `endpoint`: ```php // API2D(国内 OpenAI 中转) AI::create(['model'=>'gpt-4o', 'protocol'=>'openai', 'api_key'=>'fkxxxxx', 'base_url'=>'https://openapi.api2d.com']); // Cloudflare AI Gateway(官方聚合网关) AI::create(['model'=>'@cf/meta/llama-3-8b-instruct', 'protocol'=>'openai', 'api_key'=>'xxx', 'base_url'=>'https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}'); // one-api / new-api(自建聚合网关,最通用) AI::create(['model'=>'glm-4.6', 'protocol'=>'openai', 'api_key'=>'sk-xxx', 'base_url'=>'https://gateway.example.com']); // 自建 Anthropic 兼容网关(Agent 工具调用需要 claude 协议) AI::create(['model'=>'my-agent-model', 'protocol'=>'anthropic', 'api_key'=>'k', 'base_url'=>'http://127.0.0.1:8080/gw']); // => http://127.0.0.1:8080/gw/v1/messages // 内网自建服务(无需 Key,用私有鉴权头) AI::create(['model'=>'llama3', 'protocol'=>'openai', 'base_url'=>'http://10.0.0.9:11434/v1', 'headers' =>['Authorization'=>null, 'X-Internal-Token'=>'t']]); // 内置协议 + 自定义地址:走自己的代理,但仍按该平台的协议与鉴权方式通信 AI::create(['model'=>'qwen-plus', 'protocol'=>'qwen', 'api_key'=>'sk-xxx', 'base_url'=>'https://my-proxy.example.com/dashscope']); ``` 以上所有接入方式均支持流式输出、附件、回调等完整功能。 > **模型列表端点**:`listModels()` 会跟随 `base_url` 走同一个网关;只配了 `endpoint` 时,库按对话端点同源推导(如 `.../v1/chat/completions` → `.../v1/models`)。若网关的模型列表路径特殊,用 `endpoint_models` 单独完整覆盖(仅对 `listModels()` 生效)。OpenRouter 的模型列表接口返回完整定价数据,`listModels(true)` 可获取。 当前实际请求端点可随时查询: ```php echo $ai->resolveEndpoint(); // 返回按配置解析后的实际端点 ``` --- ## Claude Code CLI 程序调用 `Ai\Cli\ClaudeCode` 直接调用本机安装的 **claude 可执行程序**(Claude Code CLI),与 `Ai\Protocol\Claude`(Anthropic HTTP API)互补。适合让 AI 直接操作工作区文件:读写文件、执行工具,配合 `acceptEdits` 权限模式实现"AI 改代码"。 ### 快速开始 ```php use Ai\Cli\ClaudeCode; $cli = ClaudeCode::create([ 'workdir' => '/var/www', // 工作目录(AI 的文件操作都发生在这里) ]); $response = $cli->chat('检查一下 index.php 的语法,有问题直接改'); echo $response->getContent(); // 最终文本 echo $response->getSessionId(); // 会话 ID echo $response->getCostUsd(); // CLI 实测费用(USD) ``` 默认参数(均经实测验证): ``` claude --print --output-format stream-json --verbose --setting-sources user,project,local --no-chrome --allowedTools "Read Edit Write Grep Glob" --disallowedTools "Bash" --permission-mode acceptEdits [--resume <会话ID>] < prompt.txt ``` - `--print`:非交互模式,一次查询后退出 - `--setting-sources user,project,local`:加载全部设置来源。**print 模式下 CLI 默认不加载全部来源**,不显式指定会导致项目 `CLAUDE.md`、`.claude/settings.json` 的权限规则、自定义 agent/skill 全部失效——官方 IDE 插件同样显式传这个参数 - `--no-chrome`:关闭 Chrome 集成(服务端无浏览器) - `--allowedTools Read Edit Write Grep Glob`:**免权限提示白名单**(不是"可用工具集") - `--disallowedTools Bash`:把 Bash 从工具集里摘掉,模型根本看不到(这才是硬性禁用) - `--permission-mode acceptEdits`:文件编辑免确认 - 提示词经临时文件 + stdin 重定向传入,避开命令行长度上限(SSH exec 通道受限时尤为重要) > `--allowedTools` 与 `--tools` 语义不同:前者决定"哪些工具不用问",后者(`setTools()`)决定"模型有哪些工具可用"。要真正限制能力范围用 `setTools()` 或 `setDisallowedTools()`。 ### claude 路径:自动检测 + 缓存 + 手动指定 ```php $cli = ClaudeCode::create(); // 自动检测(含缓存) $cli->setBinary('/usr/local/bin/claude'); // 手动指定(优先级最高) // 缓存控制 $cli->setBinaryCacheEnabled(true); // 是否启用文件缓存(默认 true) $cli->setBinaryCacheTtl(86400); // 缓存有效期秒(默认 1 天) $cli->clearBinaryCache(); // 清除缓存,强制重新探测 ``` 自动检测顺序:`command -v claude`(PATH)→ 常见安装路径(`~/.local/bin`、Homebrew、`/usr/bin`)→ nvm 最新版本 `~/.nvm/versions/node/*/bin/claude` → 登录 shell `command -v`。结果缓存到进程内 + 文件(默认 `sys_get_temp_dir()/ai_claude_binary_cache.json`),避免 PHP-FPM 下每次请求重复探测。 ### 自定义 CLI 参数 所有 claude 参数均可覆盖/新增/删除: ```php $cli ->setFlag('allowedTools', 'Read Grep Glob') // 收紧工具权限 ->setFlag('model', 'claude-sonnet-4-5') // 指定模型 ->setFlag('max-turns', 3) // 限制轮数 ->setFlag('include-partial-messages', true) // 逐 token 增量事件 ->setFlag('max-budget-usd', 5) // 单轮费用上限(注意是 max-budget-usd) ->setFlag('system-prompt', '你是代码审查专家') // 替换系统提示词 ->removeFlag('verbose') // 删除默认参数 ->resetFlags(); // 恢复默认 ``` 参数名不区分下划线:`setFlag('permission_mode', 'plan')` 等价于 `--permission-mode plan`。 多值参数会按 CLI 各自的解析规则渲染,无需自己拼串:`setting-sources`/`tools`/`fallback-model` 逗号连接,`add-dir`/`mcp-config` 一个 flag 跟多个值,`plugin-dir`/`plugin-url` 重复 flag。 > **注意**:`claude` CLI 没有 `--working-dir`、`--max-budget`、`--proxy`、`--theme` 这些参数(实测会被拒绝)。工作目录用 `setWorkdir()`(内部转成 `cd ... &&`),代理走环境变量 `HTTP_PROXY`/`HTTPS_PROXY`。 ### 常用参数的专用方法 除了通用的 `setFlag()`,常用选项都有带取值校验的专用方法: ```php $cli // 权限与工具 ->setPermissionMode('acceptEdits') // acceptEdits/auto/bypassPermissions/manual/dontAsk/plan ->setAllowedTools(['Read', 'Bash(git *)']) // 免提示白名单,支持细粒度写法 ->setDisallowedTools(['Bash']) // 硬性拒绝(工具从工具集移除) ->setTools(['Read', 'Grep', 'Glob']) // 限定可用工具集,传 [] 禁用全部工具 ->setAddDirs(['/data/shared']) // 工作目录之外还允许访问的目录 ->setSkipPermissions(false) // --dangerously-skip-permissions // 模型与推理 ->setModel('claude-sonnet-5') ->setFallbackModel(['sonnet', 'haiku']) // 主模型过载时按序降级 ->setEffort('high') // low/medium/high/xhigh/max ->setThinkingTokens(31999) // 思考预算(IDE 插件用的就是这个值) // 成本与产出 ->setMaxBudgetUsd(0.5) // 超预算即终止,无人值守强烈建议设置 ->setJsonSchema(['type' => 'object', 'properties' => [...]]) // 结构化输出 // 提示词与扩展 ->setSystemPrompt('你是代码审查专家') ->appendSystemPrompt('输出务必用中文') ->setAgent('reviewer') ->setAgents(['reviewer' => ['description' => '代码审查', 'prompt' => '...']]) ->setMcpConfig(['mcpServers' => ['fs' => ['command' => 'npx']]]) ->setStrictMcpConfig() // 会话与配置源 ->setSettingSources(['user', 'project']) // 传 [] 表示不加载任何设置文件 ->setSettings('/path/settings.json') ->setFixedSessionId('550e8400-e29b-41d4-a716-446655440000') // 指定新会话 ID ->setForkSession() // 续接时分叉,不污染原会话 ->setContinueLast() // 续接当前目录最近一次会话 ->setSessionPersistence(false) // 会话不落盘 // 输出与诊断 ->setPartialMessages() // token 级增量事件 ->setIncludeHookEvents() ->setForwardSubagentText() ->setDebug('api,hooks') // --debug + --debug-to-stderr,日志走 stderr 事件 ->setBare() // 精简模式,跳过 hooks/LSP/CLAUDE.md 自动发现 ->setSafeMode(); // 禁用全部自定义配置,排查用 ``` ### 结构化输出 `setJsonSchema()` 约束模型最终必须输出符合 schema 的 JSON,`getStructured()` 直接拿数组: ```php $cli->setJsonSchema([ 'type' => 'object', 'properties' => [ 'severity' => ['type' => 'string'], 'issues' => ['type' => 'array', 'items' => ['type' => 'string']], ], 'required' => ['severity', 'issues'], ]); $res = $cli->chat('审查 src/User.php,按 schema 输出问题清单'); $data = $res->getStructured(); // ['severity' => 'high', 'issues' => [...]],解析失败返回 null ``` ### 流式调用(事件回调) `runStream()` 逐事件回调,事件语义与官方 IDE 插件一致,可直接转发给 SSE: ```php $cli->runStream('帮我重构这段代码', function ($event, $data) { switch ($event) { case 'start': break; // ['resume' => bool] case 'init': break; // 会话初始信息:cwd、session_id、可用工具、MCP 服务器 case 'text': break; // 助手正文文本块(string) case 'thinking': break; // 助手思考内容(string) case 'tool_use': break; // ['id','name','input'] case 'tool_result': break; // ['tool_use_id','content','is_error'] case 'text_delta': break; // token 级正文增量(需 setPartialMessages()) case 'thinking_delta': break; // token 级思考增量(需 setPartialMessages()) case 'rate_limit': break; // 限流状态 case 'system': break; // 其它 system 子类型(thinking_tokens、compact_boundary…) case 'error': break; // 本轮标记 is_error case 'message': break; // 原始 stream-json 事件(所有事件都会先经过这里) case 'stderr': break; // 排查日志(开 setDebug() 后调试输出走这里) case 'result': break; // 最终汇总 case 'done': break; } }); ``` `result` 事件是最终汇总:文本取自 `result.result`,费用取自 `result.total_cost_usd`(CLI 实测值,无需价格表),`is_error:true` 时 `isSuccess()` 返回 `false`。 响应对象除 `getContent()` / `getSessionId()` / `getCostUsd()` 外还提供:`getStructured()`、`getThinking()`、`getToolUses()`、`getTools()`、`getPermissionDenials()`、`getSubtype()`、`getStopReason()`、`getInit()`、`getNumTurns()`、`getDurationMs()`、`getExitCode()`、`getCommand()`。 ### 多轮会话续接 ```php $res = $cli->chat('第一问'); $cli->setSessionId($res->getSessionId()); // 下一轮自动 --resume 续接 $cli->chat('第二问', ['reset' => true]); // 强制开启新会话 ``` 每次执行后若输出带新 `session_id`,会自动回写到内部,`getSessionId()` 始终是最新的。 ### 自定义执行器(远程 / 容器化场景) 默认用 `proc_open` 本地执行。需要经 SSH/SFTP 在宿主机跑 claude 时(如 Docker 容器内 PHP),注入自定义执行器: ```php $cli->setRunner(function ($cmd, $onChunk) { $stream = ssh2_exec($conn, $cmd); // 在宿主机执行同一命令 $err = ssh2_fetch_stream($stream, SSH2_STREAM_STDERR); while ($buf = fread($stream, 8192)) { $onChunk($buf, 'out'); } while ($buf = fread($err, 8192)) { $onChunk($buf, 'err'); } return 0; }); ``` 配套设置: - `setShellPrefix('export LANG=en_US.UTF-8; ')`:注入环境(如 locale、nvm PATH) - `setPromptDir('/data/ai_prompt_tmp')`:提示词临时文件目录,容器与宿主机 1:1 挂载时指向双方可见路径(命令里的 `stdin` 重定向会在宿主机侧读取该文件) - 本地执行时默认自动把 nvm 下 claude 所在目录加入 PATH(`setAutoNvmPath(false)` 关闭) ### 进程行为:环境变量 / 信号 / 协程 以下三项对 `ClaudeCode` 与 `ClaudeCodeSession` 同时生效。 **子进程默认继承当前进程的环境变量**(`setInheritEnv(false)` 关闭) `proc_open` 收到非 null 的 env 数组时会**整体替换**子进程环境,不是叠加。因此不继承时子进程连 `HOME` 都没有,claude 只能靠 `/etc/passwd` 兜底才找得到 `~/.claude` 下的登录凭据 —— 容器里 `/etc/passwd` 常常没有对应条目,表现就是"登录态莫名丢失";`PATH` 也只剩 shell 的内置默认值,nvm 装的 node 不在其中。 继承是全量的:父进程若设了 `ANTHROPIC_API_KEY`、`CLAUDE_CODE_*` 之类变量,子进程一样读得到(可能改变 claude 的计费与行为)。需要干净环境时关掉继承,再用 `setEnv()` 精确给定。 **claude 直接取代 shell 进程**(`setExecReplace(false)` 关闭) 命令里给 claude 加了 `exec` 前缀。`proc_open` 传字符串走的是 `sh -c ""`,实测 dash 不做 exec 优化,进程树是 `sh → claude` 两层,`proc_terminate()` 的信号只打到中间那层 sh —— 超时抛完异常、`kill()` 返回之后,claude 其实还在后台跑到把整轮写完,额度照烧。加上 `exec` 后信号直达 claude 本人。 只有一种情况需要关掉:自定义执行器拿到命令串后还要往**后面**接东西(如 `$cmd . '; echo done'`,exec 之后 shell 已被替换,后面的命令不会执行),或者调用方自己已经插过 `exec`(`exec exec cmd` 在 dash 下直接报 127)。 配套的 `setKillGrace(2)`:结束进程时先发 SIGTERM 并留出这段宽限期,让 claude 自己收尾、把 session 落盘(之后还能 `--resume`),期限内没退出才 SIGKILL。设 0 表示直接强杀。 **协程环境把内部等待换掉**(`setSleeper()`) Swoole / Workerman 这类常驻协程环境里,库内部的 `usleep` 与 `stream_select` 会把整个 worker 钉死,同 worker 上的其它请求全部排队: ```php $cli->setSleeper(function ($sec) { \Swoole\Coroutine::sleep($sec); }); ``` 设置后,本地执行的轮询间隔、双工会话的事件泵与关闭流程都改走它,会话类的 `stream_select` 一并改成"轮询 + 让出",不再阻塞整个线程。 ### 信息查询(版本 / 登录态 / 模型列表 / 额度用量) 不需要发起对话就能读取 CLI 侧的各类信息,**不消耗模型额度**: ```php $cli = ClaudeCode::create(); // —— 子命令类查询(毫秒级)—— $cli->getVersion(); // '2.1.222'(实例内缓存) $cli->isLoggedIn(); // true $cli->getAuthStatus(); // ['loggedIn'=>true,'authMethod'=>'claude.ai','apiProvider'=>'firstParty', // 'email'=>'...','orgId'=>'...','orgName'=>'...','subscriptionType'=>'max'] $cli->doctor(); // 安装体检报告(原始文本) $cli->runCommand(['mcp', 'list']); // 任意子命令:['exit_code'=>0,'stdout'=>'...','stderr'=>''] // —— 控制协议类查询(秒级,内部临时起一个 claude 进程问完即关)—— $cli->listModels(); // ['default','opus[1m]','claude-fable-5[1m]','sonnet','haiku','opus'] // 返回值可直接传给 setModel() $cli->listModels(true); // 完整条目,含 resolvedModel / displayName / description / // supportsEffort / supportedEffortLevels / supportsFastMode 等 $cli->getUsage(); // 用量与限流全量数据 $cli->getRateLimits(); // 精简后的额度概览(见下) $cli->getSettings(); // 合并 user/project/local 后实际生效的设置 $cli->getMcpServers(); // MCP 服务器状态列表 $cli->getBinaryVersion(); // ['version'=>'2.1.222','buildTime'=>'2026-08-04T01:24:05Z'] ``` `listModels()` 与 `Ai\AI::listModels()` 保持同样的约定:默认返回可直接使用的模型标识数组,传 `true` 返回完整数据,适合后台下拉框渲染。 **额度查询** ```php foreach ($cli->getRateLimits() as $limit) { printf("%-14s 已用 %5.1f%% %s后重置\n", $limit['key'], // session / weekly_all / weekly_scoped $limit['percent'], // 已用百分比 gmdate('H:i:s', $limit['resets_in']) // 距重置剩余秒数 ); } // session 已用 16.0% 01:49:47后重置 // weekly_all 已用 17.0% 06:39:47后重置 // weekly_scoped 已用 0.0% 06:39:47后重置 ``` 每项还含 `severity`(`normal` / 告警级别)、`resets_at`(ISO8601)、`is_active`。按订阅计费时 `limit_dollars` 类字段为 `null`,只提供百分比;额度不可用时返回空数组。 `getUsage()` 的完整结构: | 键 | 说明 | |---|---| | `session` | 当前进程的 `total_cost_usd`、耗时、代码增删行数、分模型用量 | | `subscription_type` | 订阅类型,如 `max` / `pro` | | `rate_limits` | 各限流窗口原始数据 + 归一化后的 `limits` 数组 + `extra_usage` 额度包信息 | | `behaviors` | 近一天 / 一周的 `request_count`、`session_count` 等统计 | **在会话中查询**:`ClaudeCodeSession` 上同名方法会复用已运行的进程,因此 `getUsage()` 拿到的 `session.total_cost_usd` 就是本会话的真实累计花费;另有 `getSessionCost()` 返回同交互式 `/cost` 的文本报告。 ```php $s = ClaudeCodeSession::create(['workdir' => '/var/www']); $s->send('第一问'); $s->send('第二问'); $usage = $s->getUsage(); echo $usage['session']['total_cost_usd']; // 两轮累计花费 echo $s->getSessionCost(); // Total cost: $0.0067 // Total duration (API): 36s // Total code changes: 0 lines added, 0 lines removed // Usage by model: // claude-haiku-4-5: 10 input, 52 output, 14.1k cache read, 2.5k cache write ($0.0067) ``` > CLI 的 `get_context_usage`(上下文窗口占用)只在交互式 UI 下响应,headless 模式不回包,故本库未提供对应方法。 ### 常驻双工会话(ClaudeCodeSession) `Ai\Cli\ClaudeCodeSession` 复刻官方 IDE 插件(VSCode / JetBrains)的进程工作方式:claude 以**长驻进程**运行,stdin 持续接收 JSON 消息,stdout 持续吐事件,工具权限通过 stdio 上的 `control_request` 协议实时回调给 PHP 决策。 插件实测启动参数: ``` claude --output-format stream-json --verbose --input-format stream-json --max-thinking-tokens 31999 --permission-prompt-tool stdio --setting-sources=user,project,local --permission-mode auto --debug --debug-to-stderr --enable-auth-status --no-chrome --replay-user-messages ``` 本类默认采用其中与服务端场景相符的部分(双工 + stdio 权限回调 + 全设置源 + 消息回显 + 禁用 Chrome);权限模式保持更保守的 `acceptEdits`,思考预算和调试日志默认不开,需要时用 `setThinkingTokens(31999)` / `setDebug()` 打开。 ```php use Ai\Cli\ClaudeCodeSession; $s = ClaudeCodeSession::create([ 'workdir' => '/var/www', 'turn_timeout' => 300, // 单轮等待上限(秒) ]); // 工具权限实时决策,等价于 IDE 里弹出的"是否允许执行" $s->onPermission(function (array $req) { // $req: tool_name / display_name / input / description / tool_use_id / permission_suggestions if ($req['tool_name'] === 'Bash') return '本环境禁止执行 shell 命令'; // 字符串 = 拒绝并说明理由 if ($req['tool_name'] === 'Write' && strpos($req['input']['file_path'], '/etc/') === 0) { return false; // false = 拒绝 } return true; // true = 放行 // 也可返回 ['behavior' => 'allow', 'updatedInput' => [...]] 放行并改写入参 }); $a = $s->send('看一下 src 目录结构'); $b = $s->send('把刚才第一个文件的注释补全'); // 同一进程,上下文常驻,无需 --resume 重放 $s->close(); ``` **与一次性模式的区别** | | `ClaudeCode` | `ClaudeCodeSession` | |---|---|---| | 进程 | 每轮起停一次 | 长驻,多轮共用 | | 多轮 | `--resume` 重放历史 | 上下文常驻内存 | | 工具权限 | 靠 flag 静态配置 | 逐次回调 PHP 决策 | | 中断 | 只能 kill 进程 | `interrupt()` 优雅中断 | | 运行时改配置 | 不支持 | 热切权限模式 / 模型 / 思考预算 | | 处理中继续提需求 | 不支持 | `post()` 轮内插入 | | 事件循环 | 库内阻塞 | `tick()` 可由宿主驱动 | | 受限 PHP 环境 | 支持自定义执行器(SSH) | 仅本地 `proc_open` | **处理过程中继续提需求(非阻塞事件泵)** `send()` 一直阻塞到本轮结束,所以"claude 正在跑的时候用户又提了个新需求"在它身上做不到 —— 调用方唯一的执行流正卡在等待里。`post()` 把"投递"与"等待"拆开,事件循环交给宿主自己驱动,对齐官方 IDE 插件里的交互: ```php $s = ClaudeCodeSession::create(['workdir' => '/var/www']) ->setSleeper(function ($sec) { \Swoole\Coroutine::sleep($sec); }); // 协程环境必设 $s->post('把 src 下的注释补全'); // 立即返回,不等本轮结束 while ($clientAlive()) { while ($msg = $queue->pop()) { $s->post($msg); // 轮内轮外一样调:轮内 = 插入当前轮 } $active = $s->tick($onEvent); // 泵一批事件后立即返回 if (!$active && ($res = $s->takeResult())) { $saveAnswer($res); // 本轮收口,进程留着等下一轮 } $s->isTurnActive() ? $pause(0.02) : $pause(0.1); } $s->close(); ``` | 方法 | 作用 | |---|---| | `post($text)` / `postMessage($blocks)` | 非阻塞投递一条用户消息,返回本地消息 ID | | `tick($onEvent)` | 处理当前已可读的输出并派发事件,随即返回;返回值 = 本轮是否仍在进行 | | `isTurnActive()` | 当前是否处在一轮之中(UI 据此决定输入框与停止按钮的状态) | | `takeResult()` | 取走最近一轮的结果,取走即清空;结构与 `send()` 的返回完全一致 | 轮内插入的语义(claude CLI 2.1.207 实测): - 生效时机是**当前这次工具调用执行完之后**,不是立即打断正在跑的工具; - 整轮仍然只产生**一个** `result` 事件,`num_turns` 累计 —— 按 result 落库的话,这一条记录对应的是"多条用户消息 + 一个回复"; - 每轮 CLI 都会重发一个 `system/init` 事件(第二轮起几乎无耗时),`getInit()` 被覆盖是预期行为; - 常驻双工比"每轮起一个进程 + `--resume`"每轮省掉 3~6 秒冷启动。 比 `send()` 多出两个事件:`posted`(本地已投递,`['id','content','injected']`,`injected` 标记是否为轮内插入)与 `delivered`(CLI 已收下并回显,`['id','event']`)。UI 上建议 `post()` 返回后先标"已排队",收到 `delivered` 再改"已送达"。其余事件与 `send()` 完全一致。 `send()` 内部就是 `post()` + 循环 `tick()`,两套 API 可以混用,落库代码也可以共用。 **中断与运行时控制** `interrupt()` 就是 UI 上的"停止"按钮 —— 非阻塞化之后它才真正好用(此前唯一的调用时机是 `send()` 的事件回调里)。它让 claude 自己收尾、落 session 记录,进程保活可继续下一轮;`kill()` 则是直接收掉进程(默认先 SIGTERM、宽限 2 秒再 SIGKILL,见 `setKillGrace()`),claude 没有收尾机会。 ```php $fired = false; $res = $s->send('全量重构整个项目', function ($ev, $d) use ($s, &$fired) { if ($ev === 'tool_use' && !$fired) { $fired = true; $s->interrupt(); // 相当于 IDE 里的"停止"按钮,进程保活可继续下一轮 } }); echo $res->getSubtype(); // error_during_execution $s->setPermissionMode('plan'); // 已启动时热切换(未启动则只改启动参数) $s->switchModel('claude-haiku-4-5-20251001'); $s->switchThinkingTokens(31999); $s->control(['subtype' => 'set_cwd', 'cwd' => '/srv/app']); // 发送任意 control_request ``` **权限回调的边界(重要)** `onPermission()` 只会收到 CLI 认为"需要询问"的调用,它**不是一道完整的拦截层**。以下情况 CLI 自行放行、不问 PHP: - 设置文件(`~/.claude/settings.json`、项目 `.claude/settings.json`)里已预授权的规则 —— 用 `setSettingSources([])` 不加载 - 当前 `permission-mode` 已自动放行的类别(如 `acceptEdits` 下的文件编辑) - CLI 判定为只读、在沙箱中执行的安全命令(实测 `Bash(echo hi)` 即使 `permission-mode manual` 也不会询问) 要**硬性**禁止某个工具,用 `setDisallowedTools(['Bash'])`(工具从工具集移除,模型根本看不到)或 `setTools([...])` 限定可用集合,不要只依赖回调。 未注册 `onPermission()` 时,默认只自动放行 `Read / Edit / Write / Grep / Glob`,其余一律拒绝;`setAutoApproveTools([...])` 改名单,`allowAllTools()` 全部交回 CLI 自己判断。 其余会话方法:`start()` / `isRunning()` / `close()` / `kill()` / `sendMessage($contentBlocks)` / `getInit()` / `getAvailableTools()` / `getCommand()`。`ClaudeCode` 的全部参数方法在会话类上同样可用(需在首次 `send()` 前设置),包括 `setSleeper()` / `setInheritEnv()` / `setExecReplace()` / `setKillGrace()`(见「进程行为」一节)。 ### 环境要求 - 已安装 Claude Code CLI(`npm install -g @anthropic-ai/claude-code` 或原生安装) - 本地执行需要 `proc_open`;`shell_exec` 仅用于路径兜底探测,缺失时自动跳过 - `ClaudeCodeSession` 需要 `proc_open` 的双向管道,不支持自定义执行器(协程环境用 `setSleeper()` 让出即可,不必接管进程) --- ## 流式输出(SSE) 调用 `setStream(true)` 后,`chat()` 会在接收模型数据的同时**直接向输出缓冲区写 SSE 数据**(自动设置响应头、关闭 Nginx 缓冲),无需注册回调: ```php $ai = AI::create(['model' => 'deepseek-chat', 'api_key' => 'sk-xxx']); $response = $ai->setStream(true)->chat('写一篇关于人工智能的文章'); // 流式结束后仍可拿到完整内容与 tokens $full = $response->getContent(); $used = $response->tokens(); ``` 服务端实际输出的报文格式(固定协议,前端按此解析): ``` data: {"type":"stream_chunk","content":"人工"} data: {"type":"stream_chunk","content":"智能"} data: {"type":"stream_end","data":{"content":"人工智能……","model":"deepseek-chat","usage":{"prompt_tokens":12,"completion_tokens":300,"total_tokens":312}}} data: [DONE] ``` 对应的前端消费代码: ```javascript const res = await fetch('/ai/chat', { method: 'POST', body: formData }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop(); for (const part of parts) { const line = part.replace(/^data:\s*/, '').trim(); if (line === '' || line === '[DONE]') continue; const ev = JSON.parse(line); if (ev.type === 'stream_chunk') output.innerHTML += ev.content; if (ev.type === 'stream_end') console.log('tokens:', ev.data.usage.total_tokens); } } ``` **真实用例**——后台聊天接口(含代理、附件、流式): ```php $ai = new \Ai\AI(); if (!empty($siteConfig['PROXY_SOCKS5'])) $ai->setProxy($siteConfig['PROXY_SOCKS5']); try { $ai->setStream(true) ->setConfig(['model' => $model, 'api_key' => $apiKey]) ->setAttachments($attachments) ->chat($message); } catch (\Ai\Exceptions\AIException $e) { // 流式已开始输出时,异常信息也只能顺着流写出去 echo "data: " . json_encode(['type' => 'error', 'message' => $e->getMessage()]) . "\n\n"; } ``` > **PHP 环境注意**:流式输出会清空并关闭所有输出缓冲。如果使用会话锁(`session_start()`),建议在流式开始前 `session_write_close()`,否则同一用户的其它请求会被阻塞。 ### 常驻内存框架(Swoole / Workerman):`setStreamCallback()` 上面的默认行为是 `echo` 到输出缓冲区,**只适用于 PHP-FPM / CLI**。在 Swoole、Workerman、RoadRunner 这类常驻内存框架里,`echo` 会落到进程标准输出(日志文件),永远送不到客户端。这种场景注册回调,由调用方自己下发: ```php $ai->setStream(true)->setStreamCallback(function($event) use ($response) { if ($event['type'] === 'stream_chunk' && $event['content'] !== null) { // $response 是 Swoole\Http\Response,write() 分块下发 $response->write('data: ' . json_encode(['content' => $event['content']]) . "\n\n"); } if ($event['type'] === 'stream_end') { $response->write('data: ' . json_encode($event['data']) . "\n\n"); } }); $full = $ai->chat($messages)->getContent(); // 返回值不受影响,仍是拼好的完整文本 ``` 注册回调后库不再产生任何输出,SSE 帧格式完全由调用方决定。事件结构: | 字段 | 说明 | |------|------| | `type` | `stream_chunk` 增量分片 / `stream_end` 结束 | | `content` | `stream_chunk` 的增量文本,**已由协议层归一化**,跨平台通用;无正文的分片为 `null` | | `raw` | `stream_chunk` 的平台原始分片数组,需要平台专有字段时才用 | | `data` | `stream_end` 的汇总:`content` 完整正文、`model`、`usage` | 传 `null`(`setStreamCallback(null)`)恢复默认的直接输出。 ### 平台兼容性:40 个协议均已覆盖 「普通对话 + 流式输出 + token 统计」是每个协议的基础能力,`tests/stream_test.php` 用各平台真实的 SSE 报文逐个回放校验。三套测试都**不联网、不需要 Key、不依赖 PHPUnit**: ```bash php tests/smoke_test.php # 全部类可加载/可实例化、继承链签名兼容 php tests/stream_test.php # 40 个协议 × 普通对话 / 流式 / token 统计 php tests/tools_test.php # 工具调用跨平台一致性 php tests/lib_test.php # 并发批量 / Memory 并发安全 / 计价 / 日志注入 php tests/cli_test.php # CLI 参数渲染与命令注入防护 php tests/ssrf_test.php # SSRF 防护的全部已知绕过向量 composer test # 依次跑上面全部六套 composer analyse # PHPStan level 8 静态分析(全绿) composer compat # PHP 7.1 兼容性扫描(PHPCompatibility) composer check # 上面三样一起跑 ``` CI 会在 **PHP 7.1 / 7.2 / 7.4 / 8.0 / 8.2 / 8.4** 上跑同样的检查,并在 8.2 上额外跑一次静态分析 与 PHP 7.1 兼容性扫描。最低版本 7.1 是真的把整套测试跑一遍,不是只做语法检查。 > 库内所有公开方法都带完整的 phpdoc 类型(PHP 7.1 不支持类型化属性,类型只能写在注释里), > IDE 能正确补全 `getToolCalls()` 这类方法的返回结构。 覆盖的报文差异(都踩过坑,已在传输层统一处理): | 差异 | 说明 | |------|------| | `data:` 后**不带空格** | SSE 规范里冒号后的空格是可选的,讯飞星火等平台就不带;只认带空格的写法会导致整个流为空 | | CRLF 行尾 | 部分网关/代理会改写换行符 | | 末尾无换行符 | 最后一帧(往往正是带 `usage` 的收尾帧)没有换行时不能被丢弃 | | 夹杂 `event:` / `id:` 等字段 | Anthropic 协议每帧都带 `event:`,需正确跳过 | | 用量分帧下发 | Anthropic 把 `input_tokens` 放在 `message_start`、`output_tokens` 放在 `message_delta`,需跨帧合并 | | HTTP 200 但流里报错 | MiniMax(`base_resp`)、OpenAI 系(`error`)等出错时状态码仍是 200,需抛异常而不是返回空内容 | `usage` 中的 `prompt_tokens` / `completion_tokens` / `total_tokens` 三个标准字段在所有平台一致可用 (Anthropic 系的 `input_tokens` / `output_tokens` 已自动映射),平台特有字段原样保留。 流式下 `getStopReason()` 与 `getToolCalls()` 同样可用: ```php $resp = $ai->setStream(true)->chat(['messages' => $msgs, 'tools' => $toolDefs]); $resp->getStopReason(); // end_turn / max_tokens / tool_use …(可据此判断是否被截断) $resp->getToolCalls(); // 分片已自动重组,格式与非流式完全一致 ``` > **自定义协议**可选实现四个流式钩子(都不实现也能正常流式,只是拿不到对应信息): > > | 钩子 | 作用 | > |------|------| > | `parseStreamUsage(array $chunk): ?array` | 该帧的 token 用量,AI 层逐帧合并 | > | `parseStreamError(array $chunk): ?string` | 该帧的错误信息,非空即抛异常 | > | `parseStreamStopReason(array $chunk): ?string` | 该帧的结束原因(已归一) | > | `parseStreamToolCalls(array $chunk): ?array` | 该帧的工具调用分片,返回 `[索引 => ['id'=>, 'name'=>, 'arguments'=>片段]]`,AI 层按索引拼接 | --- ## 附件(多模态) ```php use Ai\Helpers\AIFile; $image = AIFile::fromPath('/path/to/image.jpg'); // 本地文件,自动识别 MIME $image = AIFile::fromPath($tmpName, $_FILES['x']['type']); // 指定 MIME $image = AIFile::fromUrl('https://example.com/image.jpg'); // 远程 URL $response = $ai->setAttachments([$image])->chat('描述这张图片'); ``` 附件格式由**模型层**适配:视觉模型(如 `gpt-4o`)转成各平台的图片块;不支持多模态的模型(如 DeepSeek 系列)会把附件信息以文本形式追加到最后一条用户消息,避免请求直接报错。 附件在每次 `chat()` 后自动清空,不会影响下一轮对话。 --- ## 多轮对话上下文 两种方式,按需选一: ### 方式一:设 `rounds`,库自动维护(推荐) ```php $ai = AI::create([ 'protocol' => 'qwen', 'model' => 'qwen-plus', 'api_key' => 'sk-xxx', 'rounds' => 5, // 保留最近 5 轮上下文;默认 0 = 不启用 ]); echo $ai->chat('我叫小明')->getContent(); echo $ai->chat('我叫什么名字?')->getContent(); // 模型能答出"小明" ``` `rounds` 默认为 **0(不启用)**,此时库完全不碰历史,行为与旧版本一致。 **多用户 / 常驻进程**下用 `setSessionId()` 隔离上下文,一个 AI 实例可服务多个会话: ```php $ai->setSessionId('user-1001')->chat('我喜欢喝咖啡'); $ai->setSessionId('user-2002')->chat('我喜欢喝茶'); // 与上一个会话互不干扰 $ai->setSessionId('user-1001')->chat('我喜欢喝什么?'); // 答"咖啡" ``` 历史存在内存里,进程退出即失。跨请求要持久化就自己落库: ```php // 请求结束时存起来 $redis->set("ai:history:{$uid}", json_encode($ai->exportHistory())); // 下次请求恢复 $ai->importHistory(json_decode($redis->get("ai:history:{$uid}"), true) ?: []); ``` | 方法 | 说明 | |------|------| | `setSessionId(string)` / `getSessionId()` | 切换/读取当前会话,不同会话各自独立一份历史 | | `getHistory()` / `setHistory(array)` | 读写当前会话的历史消息 | | `clearHistory(bool $all = false)` | 清空当前会话;传 `true` 清空全部会话 | | `exportHistory()` / `importHistory(array)` | 导出/导入全部会话,用于持久化 | 裁剪按「轮」而非按条数:一轮从一次真正的用户提问开始,只含 `tool_result` 的消息 算作上一轮工具调用的一部分。这样不会把 `tool_use` 和对应的 `tool_result` 切散—— 切散会让下一次请求直接被平台拒绝。 > `chatBatch()` 不读写历史:批量里每条都是独立请求,混进同一份上下文没有意义。 ### 方式二:自己维护 `messages` 不设 `rounds` 时库完全不介入,业务层自行拼接: ```php $messages = []; $messages[] = ['role' => 'user', 'content' => '我叫小明']; $resp = $ai->chat(['messages' => $messages]); $messages[] = ['role' => 'assistant', 'content' => $resp->getContent()]; $messages[] = ['role' => 'user', 'content' => '我叫什么名字?']; $resp = $ai->chat(['messages' => $messages]); ``` 需要完全掌控裁剪策略(按 token 数、按重要性摘要等)时用这种方式。 ## 请求前后回调 ```php $ai->onBefore(function (&$payload) { // 请求前:可审计、可改写 payload log_message('debug', json_encode($payload)); $payload['temperature'] = 0.5; }); $ai->onAfter(function ($response) { // onResponse() 是它的别名 // 请求后:统计 tokens、写入用量表 log_usage($response->getUsage()); }); ``` 回调内抛出的异常会被捕获并记录到 `error_log`,不会中断主流程。 --- ## 响应对象 `chat()` 返回 `Ai\Contracts\AIResponseInterface`: | 方法 | 说明 | |------|------| | `getContent(): string` | 回复文本(流式模式下为累积后的完整文本) | | `getRaw(): array` | 平台原始响应体(Agent 解析 `tool_use` 块靠它) | | `getUsage(): array` | 完整用量对象,含 `prompt_tokens` / `completion_tokens` / `total_tokens` 及平台返回的扩展字段(`prompt_tokens_details.cached_tokens`、`cache_creation_input_tokens` 等,因平台而异) | | `tokens(): int` | 总 tokens | | `getModel(): string` | 实际返回的模型名 | | `isSuccess(): bool` | 是否成功 | | `cost(array $pricing): float` | 按价格表估算费用,默认**每千 tokens**(与旧版本一致,不改动已有代码的账) | | `costPerMillion(array $pricing): float` | 同上但按**每百万 tokens**,可直接抄官网数字,如 `['prompt'=>5.0,'completion'=>25.0,'cached'=>0.5]`;`cached` 为命中缓存的输入价,两大家族的字段名都认 | | `getToolCalls(): array` | 模型发起的工具调用,已归一:`[['id'=>..,'name'=>..,'input'=>[..]]]` | | `hasToolCalls(): bool` | 本轮是否要求调用工具 | | `getStopReason(): string` | 结束原因(已归一):`end_turn` / `tool_use` / `max_tokens` / `content_filter` / `refusal` | | `getSources(): array` | 联网搜索检索到的来源,已归一,见「[来源与引用](#来源与引用)」 | | `getCitations(): array` | 回答正文里的引用(含在正文里的字符区间),已归一,见「[来源与引用](#来源与引用)」 | | `toAssistantMessage(): array` | 转成可回填进 `messages` 的 assistant 回合 | | `getError(): string` | 失败原因(仅 `chatBatch()` 这类不抛异常的场景会填充) | | `toArray()` / `__toString()` | 转数组 / 直接当字符串用 | --- ## 异常处理 ```php use Ai\Exceptions\AIException; use Ai\Exceptions\ConfigException; use Ai\Exceptions\RequestException; try { $response = $ai->chat($prompt); } catch (ConfigException $e) { // 配置错误:模型不存在、未设置模型、协议未初始化 } catch (AIException $e) { // 请求失败:网络、鉴权、限流、平台报错,均在 chat() 内被包装成 AIException echo $e->getMessage(); echo $e->getPlatform(); // 出错平台,如 'openai' echo $e->getErrorCode(); // 平台错误码 print_r($e->getRawResponse()); // 平台原始错误响应,排查问题的关键 } ``` `RequestException` 由传输层抛出,`chat()` 内部会将其转成携带平台信息的 `AIException`,业务层通常只需捕获 `AIException`。 --- ## 健壮性:超时与自动重试 AI 接口最高频的失败不是「打不通」,而是 **429 限流**和 **5xx 临时故障**。库默认已处理: ```php $ai->setTimeout(120); // 默认 120 秒(推理模型单次经常跑一两分钟,60 秒会误杀) $ai->setRetry(2); // 默认重试 2 次;传 0 关闭 $ai->setRetry(3, 800, 30000); // 重试 3 次、退避基数 800ms、单次等待上限 30 秒 ``` | 情形 | 行为 | |------|------| | 408 / 409 / 429 / 500 / 502 / 503 / 504 / 529 | 自动重试 | | 响应带 `Retry-After`(秒数或 HTTP 日期) | 优先按服务端给的时间等待,并有上限保护 | | 无 `Retry-After` | 指数退避 + 抖动(避免多进程被同时限流后又齐刷刷重试) | | 4xx(除上表)如 400 / 401 / 403 | **不重试**,立即抛异常 | | 流式请求 | **不重试**——分片已经吐给调用方,重试会造成重复输出 | | 请求体含非 UTF-8 字节 | 立即抛 `RequestException` 并说明原因(此前会静默发出空请求体) | 需要接入连接池、换 HTTP 客户端或在单元测试里注入假传输层时,替换整个传输层: ```php $ai->setTransport(new MyTransport()); // 实现 Ai\Contracts\TransportInterface 即可 ``` ### 并发批量:`chatBatch()` 批量翻译、摘要、打标签这类场景串行跑,总耗时是「单条 × 条数」,且每条都要重做一次 TLS 握手。`chatBatch()` 用 `curl_multi` 并发,总耗时约等于「最慢的一条 × 批次数」: ```php $results = $ai->chatBatch([ 'title' => '把这句翻译成英文:你好', 'intro' => '把这句翻译成英文:世界', 'body' => ['messages' => [['role' => 'user', 'content' => '翻译:再见']]], ], 5); // 第二个参数是并发度,默认 5 foreach ($results as $key => $r) { if ($r->isSuccess()) { echo $key, ': ', $r->getContent(), "\n"; } else { echo $key, ' 失败: ', $r->getError(), "\n"; // 单条失败不影响其它条 } } ``` - 返回结果与入参**键名一一对应且保序**,可直接与原数组对齐 - **单条失败不抛异常**,返回 `isSuccess()` 为 false 的响应,用 `getError()` 取原因—— 批量场景不该因为一条失败就丢掉其它已经跑完的结果 - 不支持流式(并发流式的分片会互相穿插) - 并发度调大容易触发平台限流,配合 `setRetry()` 使用 ### 日志:接到你自己的日志系统 库内不再硬编码 `error_log()`。注入后拉取模型列表失败、流式回调异常等都会走你的日志: ```php use Ai\Helpers\Log; Log::setLogger($monolog); // 任何 PSR-3 风格对象(不引入 psr/log 依赖) Log::setLogger(function ($level, $message, array $context) { log_message($level, '[AI] ' . $message . ' ' . json_encode($context)); }); Log::setLogger(null); // 恢复默认的 error_log ``` --- ## Agent:工具调用循环 `Ai\Agent\Agent` 实现完整的 agentic 循环:模型决定调用哪个工具 → 库执行工具 → 结果回填给模型 → 继续,直到模型给出最终答复或达到迭代上限。v2.0 起内部采用 `AgentRuntime` 架构,新增面向对象的工具接口与防死循环守卫。 **40 个协议全部可用**。各家把同一件事写成了两套结构,库在协议层吃掉了差异: | | OpenAI 系(36 个协议) | Anthropic 系(4 个协议) | |---|---|---| | 工具定义 | `{type:'function', function:{name, parameters}}` | `{name, description, input_schema}` | | 模型发起 | `message.tool_calls[]`,`arguments` 是 JSON **字符串** | content 里的 `tool_use` 块,`input` 是**数组** | | 结果回填 | 独立的 `{role:'tool', tool_call_id}` 消息 | user 消息里的 `tool_result` 块 | | 结束原因 | `finish_reason: 'tool_calls'` | `stop_reason: 'tool_use'` | | 系统提示 | `messages` 首条 `role:'system'` | 顶层 `system` 字段 | 业务层只写**一套**(库的统一格式,采用 Anthropic 风格),换平台只改 `protocol`: ```php $agent = (new Agent($ai))->setTools($tools)->onEvent($handler); $agent->run([['role' => 'user', 'content' => '北京天气怎么样']]); // 上面这段代码,$ai 换成下面任意一个都不用改: AI::create(['protocol' => 'qwen', 'model' => 'qwen-plus', 'api_key' => '...']); AI::create(['protocol' => 'zhipu', 'model' => 'glm-4.6', 'api_key' => '...']); AI::create(['protocol' => 'doubao', 'model' => 'doubao-seed-1-6', 'api_key' => '...']); AI::create(['protocol' => 'openai', 'model' => 'gpt-4o', 'api_key' => '...']); AI::create(['protocol' => 'claude', 'model' => 'claude-opus-5', 'api_key' => '...']); ``` 完整可运行示例见 `examples_agent.php`;跨平台一致性由 `tests/tools_test.php` 保证。 **流式跑 Agent**:`setStream(true)` 后每轮正文实时吐给回调,工具调用照常工作—— 库会把各平台分片下发的 `tool_calls` 重组回来(OpenAI 系按 `delta.tool_calls[].index` 拼 `arguments` 字符串,Anthropic 系按 `content_block_start` + `input_json_delta` 拼)。 适合聊天界面:用户能一边看模型说话,一边看它去调工具。 ```php $ai->setStreamCallback(function ($event) use ($response) { if ($event['type'] === 'stream_chunk' && $event['content'] !== null) { $response->write('data: ' . json_encode(['content' => $event['content']]) . "\n\n"); } }); (new Agent($ai)) ->setStream(true) // 默认关闭,与旧版本一致 ->setTools($tools) ->run([['role' => 'user', 'content' => '北京天气怎么样']]); ``` Agent 只是**临时借用**你的 AI 实例:跑完会把 `setStream()` 恢复原状(异常路径也会), 不会影响后续无关的 `chat()`。 > 也可以直接写 OpenAI 原生格式(`{type:'function'}` 的工具定义、`role:'tool'` 的消息), > 库会识别并转成目标平台的结构,不强制迁移已有代码。 ### chat():多轮对话 `run()` 是**无状态**的单次运行——调用方自备完整 `messages`,跑完不记账。要接着上一轮继续说,用 `chat()`:上下文由库维护,每次只说新的那句,返回 `AgentResult`。 | | `run(array $messages)` | `chat($input)` | |---|---|---| | 上下文 | 调用方自备完整消息 | 库维护,只传新的那句 | | 返回 | `void`(文本取 `lastText()`) | `AgentResult` | | 适合 | 单次任务、自己管历史 | 聊天界面、连续追问 | ```php $agent = (new Agent($ai))->setTools($tools); echo $agent->chat('我叫小明')->getText(); echo $agent->chat('我叫什么?')->getText(); // 记得住;中途的工具往返也留在上下文里 ``` 上下文存哪儿:默认在实例内存里,进程退出即失;挂上 `SessionManager` 就落到会话,跨请求、跨进程都能接着聊(见[会话持久化](#会话持久化))。 > ⚠️ 用 `chat()` 时**不要**给 `$ai` 设 `rounds`:上下文由 Agent 维护,AI 层再拼一份会翻倍。 **等授权时可以直接说新的。** 上一轮停在 `permission_denied`(等用户决策)时,除了 `approve()` / `deny()`,还可以直接 `chat()` 说别的——等于告诉模型「那个别做了,改做这个」: ```php $agent->chat('把 /data 下的临时文件删了'); if ($agent->isAwaitingPermission()) { $agent->chat('先别删,改成移到 /backup'); // 不必 approve/deny,直接改口 } ``` 库会先给那个没被批准的工具调用补一条「未执行」的结果,再把这句话作为同一条消息里的文本块接在后面。补结果这一步不是可选的:模型发出的每个 `tool_use` 都必须有一个 id 对得上的 `tool_result`,缺了下一次请求直接被平台判 400——不是降级,是整轮发不出去。 | 方法 | 说明 | |------|------| | `chat($input)` | 说一句话(也接受一条/一批消息),返回 `AgentResult` | | `getConversation()` | 读当前上下文;挂了 `SessionManager` 时读的是会话里那份 | | `setConversation(array)` | 灌入上下文,用于自建持久化 | | `clearConversation()` | 清空,下一次 `chat()` 从头开始 | | `isAwaitingPermission()` | 上一轮是否停在等授权 | ### 不用 Agent,自己控制循环 `AIResponse` 提供了平台无关的取用接口: ```php $resp = $ai->chat(['messages' => $messages, 'tools' => $toolDefs]); if ($resp->hasToolCalls()) { // 各平台一致 $messages[] = $resp->toAssistantMessage(); // 把模型这一轮接回上下文 $results = []; foreach ($resp->getToolCalls() as $call) { // ['id'=>.., 'name'=>.., 'input'=>[..]] $results[] = [ 'type' => 'tool_result', 'tool_use_id' => $call['id'], 'content' => myHandler($call['name'], $call['input']), ]; } $messages[] = ['role' => 'user', 'content' => $results]; $resp = $ai->chat(['messages' => $messages, 'tools' => $toolDefs]); } echo $resp->getContent(); echo $resp->getStopReason(); // end_turn / tool_use / max_tokens / content_filter / refusal ``` ### 工具定义 ```php $tools = [ 'sql_query' => [ 'description' => '执行只读 SQL 查询。仅支持 SELECT/SHOW/DESCRIBE/EXPLAIN,最多返回 200 行。', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'sql' => ['type' => 'string', 'description' => '要执行的 SQL'], ], 'required' => ['sql'], ], 'handler' => function (array $input) { $sql = trim((string)($input['sql'] ?? '')); if (!is_readonly_sql($sql)) return 'ERROR: 只允许只读查询'; return json_encode(db_query($sql), JSON_UNESCAPED_UNICODE); }, ], ]; ``` `handler` 返回的字符串会作为 `tool_result` 回填给模型;抛出的异常会被自动转成 `ERROR: 异常信息` 交给模型,不会中断循环。 ### 运行 ```php $ai = new \Ai\AI(); $ai->setConfig([ 'model' => 'deepseek-anthropic', 'api_key' => $apiKey, 'max_tokens' => 1024 * 64, ]); $emit = function ($ev) { // 把 Agent 过程实时推给前端 echo "data: " . json_encode($ev, JSON_UNESCAPED_UNICODE) . "\n\n"; flush(); }; $agent = (new \Ai\Agent\Agent($ai)) ->setSystem($systemPrompt) ->setTools($tools) ->setMaxIter(128) // 需要逐页分析的任务要给足迭代次数,默认 25 ->onEvent($emit); $agent->run([['role' => 'user', 'content' => $message]]); $reply = $agent->lastText(); // 最终自然语言回复 ``` ### 事件类型 `onEvent()` 回调会依次收到: | 事件 | 字段 | 含义 | |------|------|------| | `thinking` | `iter` | 第几轮迭代开始 | | `agent_text` | `text` | 模型输出的自然语言 | | `tool_call` | `name`、`input` | 模型决定调用某工具 | | `tool_error` | `name`、`message` | 工具执行出错(已回填给模型) | | `tool_permission` | `name`、`input`、`request_id` | 需要用户授权 | | `context_compact` | `tokens`、`messages` | 上下文压缩开始 | | `context_compact_done` | `messages` | 压缩完成 | | `done` | — | 正常结束 | | `error` | `message` | 出错或达到最大迭代步数 | 每个事件统一携带以下字段,便于 SSE / WebSocket 断线重连: | 字段 | 类型 | 说明 | |------|------|------| | `id` | `string` | 自增事件 ID(`evt_1_xxx`) | | `session_id` | `string` | 会话 ID | | `agent_id` | `string` | Agent 标识 | | `turn_id` | `string` | 当前迭代轮次 | | `timestamp` | `float` | Unix 时间戳(微秒精度) | 工具内部的细粒度事件(如 diff、进度)由各 `handler` 自行通过闭包发出,库不做假设。 ### 执行结果(AgentResult) `Agent::run()` 仍保持向后兼容返回 `void`,但通过 `$agent->getRuntime()->run()` 可获得完整的 `AgentResult` 对象: ```php // 兼容旧版:直接取最终文本 $agent->run([['role' => 'user', 'content' => '北京天气']]); echo $agent->lastText(); // 新版:通过 getRuntime() 获取结构化结果 $result = $agent->getRuntime()->run($messages); echo $result->getText(); // 模型最终回答 echo $result->getStopReason(); // end_turn / max_iter / no_progress / ... echo $result->getIterations(); // 迭代次数 print_r($result->getUsage()); // token 用量 if ($result->isDone()) { echo '正常结束'; } elseif ($result->isError()) { echo '异常停止:' . $result->getStopReason(); } ``` | 方法 | 返回 | 说明 | |------|------|------| | `getText()` | `string` | 模型最终自然语言回复 | | `getStopReason()` | `string` | 停止原因(见 StopReason) | | `getToolCalls()` | `array` | 本轮工具调用序列 | | `getUsage()` | `array` | 累计 token 用量 | | `getIterations()` | `int` | 实际迭代次数 | | `isDone()` | `bool` | 是否正常结束 | | `isError()` | `bool` | 是否异常停止 | ### AgentToolInterface:面向对象的工具定义 v2.0 起工具可以写成对象,实现 `Ai\Agent\Tool\AgentToolInterface`: ```php use Ai\Agent\Tool\AgentToolInterface; use Ai\Agent\Tool\ToolContext; use Ai\Agent\Tool\ToolResult; class ReadFileTool implements AgentToolInterface { public function name() { return 'read_file'; } public function description() { return '读取工作区内的文件内容'; } public function schema() { return ['type' => 'object', 'properties' => ['path' => ['type' => 'string']]]; } public function execute(array $input, ToolContext $context): ToolResult { return ToolResult::success('文件内容'); } } $agent->setTools([new ReadFileTool()]); ``` 对象工具与旧格式闭包工具可混用: ```php $agent->setTools([ new ReadFileTool(), // 对象 'get_weather' => [ // 闭包(旧格式) 'description' => '查询天气', 'input_schema' => [...], 'handler' => function (array $in) { return '晴'; }, ], ]); ``` #### 并行安全标记 实现 `ParallelSafeToolInterface` 可标记工具为并行安全,只读工具(read_file / grep / glob)可并行执行: ```php use Ai\Agent\Tool\ParallelSafeToolInterface; class ReadFileTool implements AgentToolInterface, ParallelSafeToolInterface { public function isParallelSafe() { return true; } } ``` 不实现此接口的工具默认按「不可并行」处理,安全优先。 ### ClaudeCodeTools:内置工具工厂 `Ai\Agent\Tools\ClaudeCodeTools` 提供一键创建 Agent 完整内置工具集的能力,与 Claude Code CLI 默认工具集对齐: ```php use Ai\Agent\Tools\ClaudeCodeTools; // 全部工具:Read / Write / Edit / Glob / Grep / Bash $agent->setTools(ClaudeCodeTools::all([ 'workdir' => '/var/www/project', ])); // 只读工具集(适合 plan 模式):Read / Glob / Grep $agent->setTools(ClaudeCodeTools::readOnly([ 'workdir' => '/var/www/project', ])); // 联网工具(默认不装配,需显式启用):web_fetch / web_search / translate $agent->tools(ClaudeCodeTools::web()); ``` 六个内置工具详情: | 工具 | 说明 | 并行安全 | |------|------|---------| | `read_file` | 读取文件,支持 offset/limit 分页 | ✅ | | `write_file` | 创建或覆盖文件,自动创建目录 | ❌ | | `edit_file` | 精确局部替换(str_replace 语义);`edits` 数组可**批量原子编辑**(全成功才落盘) | ❌ | | `glob` | 按 glob 模式匹配文件路径(可靠递归 `**`,**尊重 `.gitignore`**) | ✅ | | `grep` | 按模式搜索内容;探测到 `ripgrep` 自动加速,支持 `ignore_case` / 上下文行 / `output_mode` | ✅ | | `bash` | 执行 shell 命令,超时自动终止;`run_in_background=true` 可**后台跑长任务** | ❌ | | `bash_output` | 读取后台命令的增量输出(`action=read/kill/list`) | ❌ | 所有文件工具都受 `PathSafety` 沙箱保护,无法逃逸 workdir 范围。 **二进制防护**:`read_file` 读到图片/PDF/压缩包时不会把原始字节回传(那会产生非法 UTF-8, 让下一次请求的 `json_encode()` 失败、整个 Agent 运行中断),而是给出类型、大小、图片宽高等 结构化说明;`grep` 同样跳过二进制文件。 #### 后台长任务(bash + bash_output) 构建、测试、装依赖这类长任务不该把 Agent 阻塞在一次工具调用里: ```php // 模型侧:bash(command: "composer install", run_in_background: true) // → 立刻拿到句柄 id,继续干别的 // 之后: bash_output(bash_id: "bg_1_ab12cd") → 取增量输出 // bash_output(action: "list") → 列出全部后台任务 // bash_output(bash_id: "...", action: "kill") → 结束 ``` `bash_output` 每次只返回**上次之后的新输出**,适合轮询进度;进程结束后带上退出码。 #### 精确代码导航(lsp,按需启用) `code_index` 是**词法级**的,自述「callers 可能不准,当作重构唯一依据不行」;语言服务器有完整类型系统, 改接口找全部实现、跳定义、看真实签名只有它做得准。`Ai\Agent\Tools\LspTool` 把它接给模型: ```php use Ai\Agent\Tools\LspTool; use Ai\Agent\Tools\PathSafety; $agent->tools(['lsp' => new LspTool( new PathSafety('/var/www/project'), 'intelephense', ['--stdio'], // PHP;其它语言换 gopls / typescript-language-server 等 ['rootPath' => '/var/www/project'] )]); // action:definition 跳定义 / references 找引用 / hover 看签名 / symbols 列符号 // line 从 1 起(与 read_file、grep 一致),character 从 0 起(LSP 原生列号) ``` 语言服务器是**可选**外部程序,不进 composer 依赖;探测不到时工具会明确提示改用 `grep` / `code_index`, 而不是静默给错答案。传输复用 `McpStdioTransport`——LSP 与 MCP 同为「Content-Length 分帧 + JSON-RPC over stdio」。 分工:**精确导航走 `lsp`,快速缩小范围走 `code_index`/`grep`。** #### code_index 的可选 AST 增强 装了 `nikic/php-parser`(`composer require --dev nikic/php-parser`)后,`code_index` 自动改走 AST 解析: 类名解析成全限定名(分组 `use`、别名、`implements`、参数类型、静态调用都拿到 FQN),闭包内部的调用也不会漏。 没装则维持 token 级解析,功能不受影响——**零运行时依赖的承诺不变**。 > AST 只给语法结构、给不了类型:`$obj->save()` 的接收者类型推断仍需 `lsp`。 #### 模型驱动的反思(LlmReflectionStrategy) 默认反思靠固定关键词表判「任务完成没有」,换个措辞、说中文就判不出来。可换成让模型自己判: ```php use Ai\Agent\Reflection\ReflectionManager; use Ai\Agent\Reflection\LlmReflectionStrategy; $rm = new ReflectionManager(); // 强烈建议接上兜底:模型判不出来时退回内置判据,而不是直接停 $rm->setStrategy(new LlmReflectionStrategy($ai, ['fallback' => $rm->defaultStrategy()])); ``` 模型按 `{"done":…,"reason":…,"next":…}` 的 JSON 契约作答;请求异常或返回不可解析时走 `fallback`, 没给 `fallback` 则保守判「完成」并写明原因——判官坏了就停下让人看到,好过在循环里空转烧钱。 #### Notebook 编辑(notebook_edit,按需启用) `.ipynb` 是 JSON,用 `read_file` 整份读会把 `outputs` 里的 base64 图片全灌进上下文。 `Ai\Agent\Tools\NotebookEditTool` 按 cell 读写,输出只给摘要: ```php use Ai\Agent\Tools\NotebookEditTool; use Ai\Agent\Tools\PathSafety; $agent->tools(['notebook_edit' => new NotebookEditTool(new PathSafety('/var/www/project'))]); // action=read 列出各 cell;replace / insert / delete 按 cell_index 改动 ``` 它**不在** `all()` 里——工具 schema 每次请求都占 token,notebook 在 PHP 项目里属小众,按需注册即可。 #### 联网工具(web_fetch / web_search / translate) 自 v2.1 起,`ClaudeCodeTools::web()` 提供三个**默认不装配**的联网工具,需显式 `->tools(...)` 启用(避免给所有 Agent 平白开外网)。它们属外呼,默认经 `PermissionManager` 把关(`manual` 询问,`dont_ask` / `bypass` / `auto` 放行): | 工具 | 说明 | |------|------| | `web_fetch` | 抓取网页正文(HTML 自动转纯文本),复用 `Ai\Tools\HttpFetch` 做 SSRF 安全防护(拒内网/回环/云元数据,逐跳重校验重定向) | | `web_search` | 关键词网页搜索,返回标题/链接/摘要(DuckDuckGo Lite,免费无密钥) | | `translate` | 文本翻译(Edge 免费接口,无需密钥) | **渐进增强**:`grep` 探测到 `rg`(ripgrep)、`glob` 探测到 `git` 就自动用它们换取性能/精度,探测不到则回退纯 PHP 实现——外部二进制只是可选增强,不是依赖。 #### 文本翻译(Translate helper) `Ai\Helpers\Translate` 可直接翻译文本,Edge 免费接口为主、库自带 LLM 兜底: ```php use Ai\Helpers\Translate; // 数组入参返回数组,字符串入参返回字符串 $en = Translate::to('项目使用 CodeIgniter 3', 'en', ['from' => 'zh-Hans']); // Edge 不可用时用库自带 LLM 兜底 $en = Translate::to($texts, 'en', ['from' => 'zh-Hans', 'ai' => $ai]); ``` 配套脚本 `php bin/translate-readme.php README.md > README-en.draft.md` 可把中文 Markdown 按段落翻成英文**初稿**(代码围栏原样保留),平台名/模型名/端点 URL 需人工校对后再落地。 #### 上下文:文件读取去重与自定义 tokenizer 长任务里模型常为"再确认一下"重复读同一个文件,每次全文都再进一次上下文——token 白烧, 旧内容还可能与改动后的现状矛盾。`ContextManager` 会在压缩前先折叠重复读取,同一文件只留最新一份 (只改 `tool_result` 的内容字符串,不动 `tool_use_id`,不破坏配对): ```php $cm->dedupeFileReads(); // 返回被折叠的份数;compact() 内部会先自动调用 // 内置 token 数是字符估算;各模型分词规则不同,可注入精确计数器 $cm->setTokenizer(function ($text) { return my_tokenizer_count($text); }); ``` #### 输出截断:Text 助手 工具输出、命令回显、diff 正文都要限长。`substr()` 会把汉字劈成两半(非法 UTF-8 会让 `json_encode()` 失败),`mb_substr()` 又是按字符算、限不住字节,所以内置工具统一走 `Ai\Helpers\Text`: ```php use Ai\Helpers\Text; Text::cutBytes($output, 1024); // 限字节,不切开字符(工具输出、diff 用这个) Text::cutChars($summary, 200); // 限字符(给人看的摘要用这个) Text::ellipsis($text, 200); // 限字符并补省略号,未超长时不加 Text::isValidUtf8($text); // 校验 Text::length($text); // 字符数 ``` 自己写工具时,凡是会回填给模型的内容都用 `cutBytes()`。 ### 权限系统 Agent 内置 6 种权限模式,控制工具调用的放行策略: | 模式 | 说明 | |------|------| | `manual` | 只读工具自动放行,危险工具(bash)询问用户 | | `auto` | 全部自动放行 | | `plan` | 仅允许只读工具,其余拒绝 | | `accept_edits` | 允许文件修改,bash 等高风险操作询问 | | `dont_ask` | 自动放行(不询问) | | `bypass` | 全部放行(⚠️ 不安全,不要用于不可信输入) | ```php $agent->setPermissionMode('plan'); // 只读模式 $agent->setPermissionMode('manual'); // 危险工具询问(默认) $agent->setPermissionMode('bypass'); // 全部放行 ``` #### 规则匹配 在模式基础上,可以用 `allowTool` / `denyTool` 添加细化规则,优先级:deny > allow > 模式默认: ```php use Ai\Agent\Permission\PermissionManager; $pm = new PermissionManager('manual'); $pm->allowTool('read_file'); // 全部放行 read_file $pm->allowTool('write_file', ['path' => '/var/www/*']); // 只允许写指定目录 $pm->denyTool('bash', ['command' => 'rm -rf *']); // 拒绝危险命令 $agent->getRuntime()->setPermission($pm); ``` 也可通过快捷方式设置: ```php $agent->setPermissionMode('manual'); ``` #### 用户授权流程 当权限管理器返回 `ask` 时,Agent 暂停并发出 `PERMISSION_DENIED` 停止信号,等待业务层通过 `approve()` / `deny()` 响应: ```php $messages = [['role' => 'user', 'content' => '列出文件']]; // 业务层保存消息副本 $agent->onEvent(function ($event) { if ($event['type'] === 'tool_permission') { // 前端展示授权对话框 echo "请求授权:{$event['name']}(" . json_encode($event['input']) . ")"; $requestId = $event['request_id']; } }); // 用 getRuntime()->run() 返回 AgentResult $result = $agent->getRuntime()->run($messages); // 在外部通过 approve() / deny() 恢复执行 if ($result->getStopReason() === 'permission_denied') { $requestId = $result->getExtra()['request_id'] ?? ''; // 批准:传入之前保存的消息副本,Agent 继续执行 $result = $agent->approve($requestId, $messages); // 或 $result = $agent->deny($requestId, '不需要', $messages); } ``` ### 上下文压缩 Agent 自动检测上下文长度,超过阈值时用 AI 自身将历史消息压缩为摘要,避免 token 超限。压缩以 Agent Turn(一轮完整交互)为单位,保证 `tool_use` 与对应的 `tool_result` 永远不会被拆散: ```php use Ai\Agent\Context\ContextManager; $agent->getRuntime()->setContextManager(new ContextManager( $messages, [ 'maxTokens' => 100000, // 压缩阈值(默认 100k) 'threshold' => 0.8, // 超过 80% 时触发 'keepRecent' => 10, // 保留最近 10 条消息 ] )); ``` 压缩过程自动触发 `context_compact` / `context_compact_done` 事件,前端可展示进度。 ### 会话持久化 Agent 会话可保存到文件系统,支持跨请求恢复(适用于 PHP-FPM 场景): ```php use Ai\Agent\Session\FileSessionStore; use Ai\Agent\Session\SessionManager; $store = new FileSessionStore('/tmp/agent_sessions'); $agent ->setSessionId('user-abc-123') ->setSessionManager(new SessionManager($store)); // 下次请求换一个新实例,只要 sessionId 一样就接得上—— // chat() 自己从会话里读上下文,不用手动把消息传回去 $agent->chat('接着上次说'); ``` 用 `run()` 的场合(自备完整消息的单次运行),续跑要自己把消息取回来: ```php $agent->run($messages); $session = $store->load('user-abc-123'); if ($session) { $agent->run($session->getMessages()); // run() 以会话里的消息为准 } ``` > `run()` 在会话存在时**以会话消息为准**,传进去的那份被视为它的旧副本。所以别拿 `run()` 追加新一轮提问——那句话会被覆盖掉且不报错。追加走 [`chat()`](#chat多轮对话)。 会话状态流转:`running` → `paused`(等待授权)→ `running`(恢复)→ `completed` / `interrupted`。 ### 默认持久化与双根存储 自 v1.63 起,Agent 具备**惰性自动持久化**:不必显式挂 `SessionManager` / `MemoryManager`,只要表达了持久化意图(设置 `setSessionId()`、`setUserId()`、`setAgentHome()` 或 `setProjectRoot()` 之一),库就会自动把会话与记忆落到磁盘。 ```php $agent ->setWorkdir('/var/www/project') ->setUserId('u_10086') // 多租户场景的硬要求:按用户隔离 ->setSessionId(Agent::newSessionId()); $agent->chat('记住我偏好深色主题'); // 换个新实例,只要 userId + sessionId 一致就接得上 ``` > **行为变化(升级须知)**:从本版本起,仅调用 `setSessionId()` 也可能产生磁盘数据。若只想要纯内存行为,显式关闭: > > ```php > $agent->autoPersist(false); > ``` **双根存储**:数据分两处落盘,语义不同—— | 根 | 位置 | 内容 | 权限 | |----|------|------|------| | 项目根 | `/.agent/AGENT.md` | 对团队/其他 Agent 也有价值的项目长期知识(可进 Git) | `0755` | | HOME 根 | `~/.agent/`(`users/`、`projects/`、`sessions/` …) | 含用户输入/输出、绝对路径、session ID 等 | `0700` | 项目根从 `workdir` 向上寻找 `.agent/`(到 HOME 必停,最多 10 层,不依赖 `.git`,不用 `realpath()`);找不到则用 `workdir`。跨目录部署(蓝绿发布、CI 与开发机路径不同)若需复用会话,显式 `setProjectRoot()` 固定项目身份。 **多租户隔离**:`setUserId()` 后,用户记忆与会话按用户分片存放,原始 `userId` **不进磁盘路径**(只存其 SHA-256 分片)。会话加载时做 ownership 校验:当前 `userId` / 项目身份与会话记录不符则拒绝加载。`sessionId` 由 `Agent::newSessionId()` 生成(128 bit 随机),但它**不是授权凭证**,应用层仍须校验用户是否拥有目标会话。 **JSONL 会话存储**:自动挂载使用 `JsonlSessionStore`——每个会话一个 `.jsonl`(每行一个事件)+ `.state.json`(非消息状态)。消息通常只增不减,追加一行的成本是 O(新增);只有 compact / rollback 时才整份重写(临时文件 + rename 原子替换)。整个读改写事务在 `.lock` 独占锁下完成,避免并发 lost update。显式 `new FileSessionStore(...)` 的旧用户不受影响,旧 `.json` 仍可读、不自动迁移;需要转换存量会话时运行 `php bin/agent-migrate-sessions.php <目录>`。 **记忆与指令的区别**——两者物理与语义分离,不要混淆: | 文件 | 性质 | |------|------| | `AGENTS.md` / `CLAUDE.md` | 人写的规则/规程,Agent 只读的 **Instruction** | | `.agent/AGENT.md` | Agent 自动维护、跨会话复用的 **Memory** | **Git**:库**不会**自动改动 `.gitignore` / `git config` / `git status`。是否提交项目记忆 `.agent/AGENT.md` 由项目自行决定;若要提交,可自行配置 `.agent/.gitignore`。 ### 子 Agent 通过 `SubAgentManager` 注册子 Agent,主 Agent 可通过 `spawn_agent` 工具派生子 Agent 执行独立任务,子 Agent 拥有隔离的上下文,不会导致主 Agent 上下文膨胀: ```php use Ai\Agent\SubAgent\SubAgentManager; use Ai\Agent\Tools\ReadFileTool; use Ai\Agent\Tools\PathSafety; $sam = new SubAgentManager($ai); $sam->register('code-reviewer', [ 'description' => '审查代码质量', 'prompt' => '你是代码审查专家,找出安全漏洞和性能问题。', 'tools' => [new ReadFileTool(new PathSafety('/var/www'))], 'max_iter' => 10, ]); $agent->getRuntime()->setSubAgentManager($sam); ``` 子 Agent 权限继承自父 Agent,且不能超越父 Agent 的权限范围。 #### 后台模式(Background SubAgent) `spawn_agent` 工具支持 `background` 参数。设为 `true` 时,主 Agent 不阻塞等待子 Agent 完成,立即返回 `task_id` 继续执行自己的任务: ```php $sam->setBackgroundRunner(function ($task) { // 在 Swoole / Workerman 协程或队列 Worker 中异步执行 return [ 'status' => 'completed', 'summary' => '子任务完成', 'iterations' => 5, ]; }); ``` - 已注入 `backgroundRunner`(协程/队列环境)→ 非阻塞执行,工具立即返回 `task_id` - 未注入 runner → 降级为同步执行(仍记录完整 transcript) #### 子 Agent Transcript(P0-5) 每次子 Agent 运行的完整消息历史、迭代次数、停止原因、最终结果都被记录,与主 Agent 的 transcript 分离保存: ```php // 获取某次运行的完整 transcript $transcript = $sam->getTranscript('sub_1_...'); // $transcript = [ // 'task_id' => 'sub_1_...', // 'agent' => 'code-reviewer', // 'task' => '审查 Auth.php', // 'status' => 'completed', // 'reason' => 'end_turn', // 'summary' => '发现了 3 个问题...', // 'messages' => [...], // 完整消息历史 // 'iterations' => 8, // 'duration_ms'=> 12500.3, // 'created_at' => 1700000000, // ]; // 查询最近运行记录 $recent = $sam->recentRuns(10); // 获取结果摘要(不含完整消息历史,适合反馈给主 Agent) $result = $sam->getResult('sub_1_...'); ``` 主 Agent 看到的只是 `spawn_agent` 工具返回的结构化摘要(含 `transcript_id`),完整 transcript 通过 `getTranscript()` 单独查询,不会导致主 Agent 上下文膨胀。 #### Worktree 隔离模式 `spawn_agent` 的 `isolation` 参数设为 `worktree` 时,子 Agent 在一个独立的 git worktree 中执行,改动不会碰到主工作区;执行完毕捕获 diff 并清理 worktree: ```php $agent->setWorkdir('/var/www/project'); // 必须是 git 仓库根目录 // 模型调用:spawn_agent(agent="refactorer", task="重构 Auth.php", isolation="worktree") $result = $sam->getResult('sub_1_...'); echo $result['diff']; // 完整 unified diff,主工作区文件未被改动 echo $result['diff_stat']; // ' src/Auth.php | 42 +++++-----' ``` 适合让子 Agent 试探性地改代码:拿到 diff 后由主 Agent 或人工决定是否应用。工作目录不是 git 仓库时该次运行返回 `status = failed`、`reason = no_git_repo`,不会退化成直接改主工作区。 ### 预算控制 `BudgetManager` 跟踪 token 用量与估算成本,超过预算自动停止: ```php // 快捷方式 $agent->setMaxBudget(5.0); // 预算 5 美元 $agent->getRuntime()->setMaxTokens(500000); // 或按 token 数限制 // 精细配置 use Ai\Agent\Budget\BudgetManager; $bm = new BudgetManager([ 'maxBudget' => 5.0, // 最大预算(美元) 'maxTokens' => 500000, // 最大 token 数 'pricing' => ['prompt' => 5.0, 'completion' => 25.0, 'cached' => 0.5], 'perMillion' => true, // 按每百万 token 计(官网价) ]); $agent->getRuntime()->setBudget($bm); ``` `BudgetManager` 还管两个与花钱无关、但同样能让任务跑飞的维度: ```php $bm = new BudgetManager([ 'maxDuration' => 300, // 墙钟上限:最多跑 5 分钟 'maxToolCalls' => 100, // 工具调用次数上限 ]); ``` 这两条是**执行预算**,与 token / 成本各管各的:一个卡在慢工具上的任务可能一个 token 都没多花,却已经占着 HTTP 连接跑了十分钟;一个反复 grep 的 Agent 可能 token 不多,工具调用已经上百次。超限时同样以 `budget_exceeded` 停止,`summary()['reason']` 会说清是哪一项: ```php $summary = $bm->summary(); // ['exceeded' => true, 'reason' => '超过墙钟上限(312.4s > 300s)', // 'tokens' => 48210, 'cost' => 0.83, 'elapsed' => 312.4, 'tool_calls' => 37] ``` 墙钟从循环开始处起算(`start()` 由循环自动调用),不是从 `BudgetManager` 构造时——它常常在装配阶段就建好,离真正开跑可能隔着很久。同一个实例跑第二个任务前调 `reset()` 清空计量。 **子 Agent 与父 Agent 共用同一本账**:委派出去的活儿花的 token、成本、工具调用都记在父账本上,父 Agent 的墙钟上限对它一样有效。跟权限同一条道理——子 Agent 的额度 ⊆ 父 Agent 的额度。不继承的话,父 Agent 设了 5 分钟上限却挡不住它派出去的活儿跑二十分钟。取消令牌一并往下传:叫停父 Agent 会把它派出去的子 Agent 一起停掉。 运行结束后,成本、耗时、改动的文件会自动填进 `AgentResult`,不必自己去各个管理器里拼: ```php $result = $agent->getRuntime()->run($messages); $result->getCost(); // 本次运行的估算成本 $result->getDuration(); // 毫秒 $result->getFilesChanged(); // 动过哪些文件 $result->toContract(); // 完整结构化结果,可直接 JSON 返回 ``` ### 模型调用的瞬时失败重试 传输层会重试 408 / 429 / 5xx,但不重试 4xx——一般来说这是对的,400 通常意味着请求本身有问题,重发多少次都一样。 实测发现这条规则有例外:把别家模型包装成 Anthropic 兼容接口的第三方网关会**偶发**吐 400。同一份字节完全相同的请求连发四次,三次成功、一次 `400 Format Error`。这个概率放到 25 轮的任务上就是必然事件——单次 1/4 的失败率,跑完一整轮几乎注定中途暴毙,而且前面所有工作一起丢。 所以循环层对模型调用另做一层重试(默认 2 次,指数退避): ```php $agent->getRuntime()->getLoop()->setModelRetries(3, 500); // 重试 3 次,首次退避 500ms $agent->getRuntime()->getLoop()->setModelRetries(0); // 关掉 ``` 它重试的是**同一个模型**(治网关瞬时故障),与 `setFallbackModels()` 分工不同——后者是重试完还不行才换模型(治模型本身不可用)。鉴权失败、模型不存在(401 / 403 / 404 / 413)立即放弃,重试只是白等。每次重试都发 `model_retry` 事件。 万一重试用尽仍然失败,结果里会**带上最后一段模型文本**而不是空字符串——模型调用挂在第 N 轮,前 N-1 轮真做过的事不该跟着一起丢: ```php if ($result->getStopReason() === 'model_error') { echo $result->getText(); // 挂掉之前它说过什么 echo $result->getExtra()['error']; // 为什么挂 echo implode(',', $result->getFilesChanged()); // 已经改了哪些文件 } ``` ### 取消:让 Agent 停得下来 生产环境里任务需要能中途停下:用户按了停止、浏览器断开、任务超时。`CancellationToken` 让循环在**安全点**收手: ```php use Ai\Agent\Loop\CancellationToken; // 1. 同进程直接取消 $token = new CancellationToken(); $agent->setCancellation($token); // …另一处(信号处理器、定时器): $token->cancel('用户中断'); // 2. Web:浏览器一断就别再烧钱 $agent->setCancellation(CancellationToken::whenConnectionAborted()); // 3. 后台任务:另一个进程 touch 一下就能叫停 $agent->setCancellation(CancellationToken::whenFileExists("/tmp/stop-{$taskId}")); // 4. 硬超时 $agent->setCancellation(CancellationToken::afterSeconds(600)); // 也可以直接叫停,没挂令牌会自动建一个 $agent->cancel('不用跑了'); ``` 检查点选在三个**安全边界**上:开新一轮之前、模型回话后工具开跑前、一批工具结果回填之后。不在这些点之外硬中断——PHP 没有安全的线程中断,半途掐掉一个正在写文件的工具,留下的是半截文件,比多跑一轮糟得多。 **取消不是放弃。** 命中后会先存检查点再以 `user_cancel` 收尾,之后可以接着跑: ```php $result = $agent->getRuntime()->run($messages); if ($result->getStopReason() === 'user_cancel') { echo $result->getExtra()['reason']; // 取消原因 echo $result->getExtra()['resumable']; // 能不能从检查点恢复 } ``` 长跑的工具(下载、全库扫描)可以在执行途中反复问「还要不要继续」: ```php $handler = function (array $in, ToolContext $ctx) { foreach ($items as $item) { if ($ctx->isCancelled()) { return '已取消,处理了 ' . $done . ' 项'; } // … } }; ``` ### 并行工具执行 当模型一次返回多个工具调用时,并行安全的工具(read_file / grep / glob)可同时执行,其余工具按顺序执行。默认关闭,需显式开启: ```php $agent->setParallelTools(true); // 启用并行 ``` 并行执行器默认按顺序执行(语义正确),在 Swoole / Workerman 协程环境可注入并行运行器实现真正并发: ```php $agent->getRuntime()->setParallelRunner(function (array $tasks) { return \Swoole\Coroutine\parallel($tasks); }); ``` ### 工具执行超时 一次工具调用(含重试等待)超过指定秒数后自动标记为超时,不再继续重试: ```php $agent->setToolTimeout(60); // 全局超时 60 秒 $agent->getRuntime()->setToolTimeout(30); // 或通过 Runtime 设置 ``` 超时判断时间点在每次执行完成和每次重试等待前,因此不会在工具执行中途强行中断(同步 PHP 无法打断用户态代码),但能阻止后续重试。框架内置的 BashTool 自带超时终止能力,可与全局超时配合使用。 超时工具的 `StopReason` 为 `timeout`,结果中的 `error` 包含超时详情。 ### 模型降级 主模型服务级错误时,Agent 自动按序切换降级模型,保持上下文和工具状态不变: ```php $agent->setFallbackModels(['claude-sonnet', 'claude-haiku']); ``` 降级发生后会发出 `model_fallback` 事件,包含当前使用的模型名与原始错误信息。所有降级模型均失败后,Agent 才以 `MODEL_ERROR` 停止。 ### 验证管理(VerificationManager) 工具执行后自动运行验证命令(如 `php -l`),确保代码改动真实可用,而不是让模型"记得自己测试"。验证失败的错误信息会回填给模型,让它自行修复。 ```php $agent->setVerification([ 'edit_file' => ['php -l {file}'], 'write_file' => ['php -l {file}'], 'test' => ['vendor/bin/phpunit'], ]); ``` 验证策略按工具名配置,支持多条命令;命令中的 `{file}` 占位符会替换为工具输入里的 `file_path`。验证通过 `exec()` 同步执行,退出码 0 视为通过,非 0 的错误输出回填给模型。 单独使用 `VerificationManager`: ```php use Ai\Agent\Verification\VerificationManager; $vm = new VerificationManager([ 'edit_file' => ['php -l {file}'], ]); $results = $vm->verify('edit_file', ['file_path' => 'src/Auth.php']); // $results[0]->isPassed() => true / false // $results[0]->getError() => 错误信息 ``` #### 验证器框架(VerifierInterface) 命令式的 `addRule()` 适合「跑一条命令看退出码」。需要解析工具输出、定位到具体文件行号时,实现 `VerifierInterface` 写一个验证器: ```php use Ai\Agent\Verification\PhpSyntaxVerifier; $verifier = new PhpSyntaxVerifier(); $verifier->supports('write_file'); // true——只处理写文件类工具 $result = $verifier->verify([ 'tool_name' => 'write_file', 'file_path' => 'src/Auth.php', ]); if (!$result->isPassed()) { echo $result->getVerifierName(); // 'php_syntax' print_r($result->getErrors()); // [['file' => 'src/Auth.php', 'line' => 42, 'message' => 'syntax error, unexpected token "{"']] } ``` `PhpSyntaxVerifier` 对写入的 `.php` 文件跑 `php -l`,并把 `Parse error / Fatal error` 输出解析成带行号的结构化错误。非 PHP 文件、文件不存在、验证器被禁用时直接返回通过,不阻断流程。 `VerificationResult` 同时保留原有的 `isPassed()` / `getCommand()` / `getOutput()` / `getError()`,新增 `getVerifierName()`、`getErrors()`、`addError()`、`toArray()`,旧代码不受影响。 #### 内置验证器 除 `PhpSyntaxVerifier` 外还有三个开箱即用的验证器,都实现 `VerifierInterface`: | 验证器 | 名称 | 作用 | |--------|------|------| | `PhpSyntaxVerifier` | `php_syntax` | 对写入的 `.php` 跑 `php -l`,解析出带行号的语法错误 | | `SecurityVerifier` | `security` | 扫描危险函数(`eval` / `exec` / `system` …)与硬编码凭据 | | `UnitTestVerifier` | `unit_test` | 改动后跑测试命令,解析失败用例名 | | `GitDiffVerifier` | `git_diff` | 统计改动规模,超出上限或碰了受保护路径即拦下 | ```php use Ai\Agent\Verification\GitDiffVerifier; use Ai\Agent\Verification\SecurityVerifier; use Ai\Agent\Verification\UnitTestVerifier; $agent->addVerifier(new SecurityVerifier()); $agent->addVerifier(new UnitTestVerifier([ 'command' => 'composer test', 'workdir' => '/var/www/project', ])); $agent->addVerifier(new GitDiffVerifier([ 'workdir' => '/var/www/project', 'maxFiles' => 10, // 一次改超过 10 个文件 → 拦下 'maxLines' => 500, // 一次改超过 500 行 → 拦下 'protectPaths' => ['composer.json', '.github/'], ])); ``` 或者一次挂全部: ```php $agent->useDefaultVerifiers([ 'test' => 'composer test', // 给了才挂 UnitTestVerifier 'workdir' => '/var/www/project', // 给了才挂 GitDiffVerifier 'maxFiles' => 10, ]); ``` `SecurityVerifier` 基于 `token_get_all()` 扫描,注释和字符串里的 `eval` 不会误报, `$db->exec()` 这类方法调用也不算内置危险函数。确实需要某个函数时用 `allow()` 放行: ```php $sec = new SecurityVerifier(); $sec->allow('exec'); // 比关掉整个验证器精确 ``` `GitDiffVerifier` 的用途是给「放手让 Agent 改代码」加护栏——一次动了 40 个文件、 几千行,通常不是任务要求的,而是模型跑偏了。目录不是 git 仓库时直接放行,不阻断流程。 ### 工作区管理(WorkspaceManager) 跟踪 Agent 当前工作区的 Git 状态,让模型了解 cwd、分支、已修改文件等。状态按需刷新(默认 5 秒缓存),不会每轮都跑 git 命令。 ```php $agent->setWorkspaceDir('/var/www/project'); // 或手动创建 use Ai\Agent\Workspace\WorkspaceManager; $wm = new WorkspaceManager('/var/www/project'); $wm->refresh(); echo $wm->getBranch(); // 'main' echo $wm->getProjectName(); // 'project' print_r($wm->getModified()); // ['src/Auth.php'] ``` 工作区状态被格式化为 `` 块,在每次迭代时自动注入系统提示词,让模型知道当前工作目录、分支、已修改文件等上下文。 **注意**:`setWorkdir()` 也会自动创建 `WorkspaceManager`(如果未显式设置),因此已有的 `setWorkdir()` 调用会自动获得工作区状态跟踪能力。 ### 技能系统(Skills) 技能(Skill)是某项能力或工作流程的详细指令,按目录组织。默认只把「名称 + 描述」注入系统提示词(节省 Context),模型需要时通过 `use_skill` 工具加载完整正文。 ```php $agent->loadSkills('/path/to/skills'); // 或手动创建 use Ai\Agent\Skill\SkillManager; $sm = new SkillManager(); $sm->register('deploy', [ 'description' => '部署项目到生产环境', 'content' => "# 部署流程\n1. 构建...", 'allowedTools' => ['Bash(git *)'], ]); $agent->setSkillManager($sm); ``` #### SKILL.md 目录格式 Skills 目录约定为 `{dir}/{skill-name}/SKILL.md`,支持 frontmatter: ```markdown --- name: deploy description: 部署项目到生产环境 allowed-tools: - Bash(git *) - Bash(docker *) --- # 部署流程 1. 执行构建 2. 上传到服务器 3. 重启服务 ``` `use_skill` 工具被自动注册到 Agent 工具注册表,模型调用后加载完整技能正文,同时收集 `allowed-tools` 中的工具限制(不能突破全局权限)。 #### 按需发现与场景匹配(Skill 2.0) 技能多起来之后,`loadFromDir()` 会把每份正文都读进内存。`discover()` 只解析 frontmatter 登记技能,正文等模型真正 `use_skill` 时再读盘: ```php $found = $agent->discoverSkills('/path/to/skills'); // ['wordpress', 'deploy', 'nginx'] ``` frontmatter 多认两个字段: ```markdown --- name: wordpress description: WordPress 插件开发 files: - wp-content - "*.wp.php" knowledge: | WordPress 通过 hooks(action / filter)扩展。 插件入口在 wp-content/plugins/。 --- # WordPress 插件开发 (完整正文,模型 use_skill 后才加载) ``` - `knowledge` 是几行要点,随技能描述一起注入系统提示词(`` 块),不是完整正文 - `files` 是触发该技能的文件通配符,用来按场景自动匹配 ```php // 按文件路径找匹配的技能 $sm->forFile('/var/www/wp-content/plugins/foo.php'); // ['wordpress' => SkillDefinition] // 直接激活——省掉模型自己判断该不该 use_skill 这一步 $agent->activateSkillsForFile('/var/www/wp-content/plugins/foo.php'); // ['wordpress'] // 只加载正文、不激活(不合并 allowed-tools) $sm->loadByName('wordpress'); ``` **没配 `files` 的技能永远匹配不到**——按技能名去猜文件路径太容易误伤,宁可要求显式声明。 ### 项目指令(InstructionManager) 加载项目级指令文件(CLAUDE.md / AGENTS.md),这些是项目必须遵守的长期规则,与 Skill 不同: - **CLAUDE.md / AGENTS.md** = 项目必须遵守的长期规则 - **Skill** = 某项能力 / 工作流程 - **Tool** = 实际执行动作 ```php $agent->loadInstructions('/var/www/project'); // 或手动创建 use Ai\Agent\Instruction\InstructionManager; $im = new InstructionManager(); $im->loadFromTree('/var/www/project'); // 加载 .claude/ .ai/ 根目录的指令 $im->loadFromDir('/var/www/project/src'); // 子目录级指令(优先级更高) $agent->setInstructionManager($im); ``` 加载优先级(后加载的优先级更高):Global → Project → Subdirectory → Task。指令内容被格式化为 `` 块注入系统提示词。 ```php // 自定义文件名 $im->setFilenames(['CLAUDE.md', 'AGENTS.md', '.ai/AGENTS.md']); ``` ### MCP Runtime(McpManager) MCP(Model Context Protocol)服务器管理,通过 stdio 子进程运行 MCP 服务器,将 MCP 工具自动注册到 Agent 工具注册表。 ```php $agent->setMcpServers([ 'filesystem' => [ 'command' => 'npx', 'args' => ['-y', '@modelcontextprotocol/server-fs', '/tmp'], ], ]); // 或手动创建 use Ai\Agent\Mcp\McpManager; $mm = new McpManager(); $mm->addServer('filesystem', 'npx', [ '-y', '@modelcontextprotocol/server-fs', '/tmp', ]); $agent->setMcpManager($mm); ``` 每个 MCP 服务器通过独立的子进程运行,通过 JSON-RPC 2.0 over stdio 通信。工具名格式为 `{serverName}__{toolName}`,避免不同服务器间的工具名冲突。 ```php // 批量配置 $mm->addServers([ 'fs' => ['command' => 'npx', 'args' => ['-y', '@modelcontextprotocol/server-fs', '/tmp']], 'db' => ['command' => 'npx', 'args' => ['-y', '@modelcontextprotocol/server-sqlite', './data']], ]); // 关闭所有 MCP 服务器 $mm->shutdown(); ``` #### 传输协议:stdio / HTTP / SSE / WebSocket MCP 是 JSON-RPC 2.0 协议,传输方式可以换。本地工具走 stdio 子进程,远程服务走 HTTP,需要长连接走 WebSocket: | 协议 | 传输方式 | 适用场景 | |------|---------|---------| | stdio | 子进程 stdin/stdout | 本地工具(文件系统、Git、数据库客户端) | | HTTP / SSE | HTTP POST,响应可为 `text/event-stream` | 远程服务、团队共享的 MCP 服务器 | | WebSocket | 长连接双向通信 | 需要服务端推送、或一次会话里连续调很多次工具 | ```php $mm = new McpManager(); // stdio(默认) $mm->registerServer('fs', ['command' => 'npx', 'args' => ['-y', '@modelcontextprotocol/server-fs', '/tmp']]); // HTTP / SSE $mm->registerServer('remote', [ 'transport' => 'http', 'url' => 'https://mcp.example.com/rpc', 'headers' => ['Authorization: Bearer xxx'], ]); // WebSocket $mm->registerServer('live', ['transport' => 'websocket', 'url' => 'wss://mcp.example.com/ws']); ``` **连接管理**——不再是「一次性全起、全关」: ```php $mm->connect('remote'); // 单独连一个,失败返回 false 而不抛异常 $mm->isConnected('remote'); // true $mm->discoverTools('remote'); // 动态发现工具列表,未连接时自动先连 $mm->disconnect('remote'); // 单独断开 print_r($mm->status()); // ['fs' => ['connected' => false, 'transport' => 'stdio'], // 'remote' => ['connected' => true, 'transport' => 'http']] echo $mm->getLastError(); // 最近一次连接失败的原因 ``` 连接失败返回 `false` 而不是抛异常——一个 MCP 服务器不可用不该让整个 Agent 停下来,具体原因从 `getLastError()` 取。 HTTP 传输会记住服务端返回的 `Mcp-Session-Id` 并自动带在后续请求上;SSE 响应里穿插的通知会被跳过,只取 id 对得上的那条。`wss://` 需要 openssl 扩展,CDP 与本地 `ws://` 不需要。 ### 作用域记忆(MemoryManager) 分作用域的长期记忆管理,支持 `user` / `project` / `session` / `task` / `agent` 五个作用域,各有独立的记忆文件。注入系统提示词时按作用域顺序合并,让模型感知不同层级的记忆上下文。 ```php $agent->setMemoryDir('/tmp/agent_memory'); // 或手动创建 use Ai\Agent\Memory\MemoryManager; $mm = new MemoryManager('/tmp/agent_memory'); $mm->remember('user', '用户喜欢 PHP'); $mm->remember('project', '项目使用 CodeIgniter 3'); $mm->remember('session', '当前正在修登录'); $mm->remember('task', '正在修改 Auth.php'); $mm->remember('agent', '上次尝试方案 A 失败'); $agent->setMemoryManager($mm); ``` 记忆文件持久化在 `{baseDir}/{scope}.md`,支持追加、覆盖、清空、读取: ```php $mm->read('user'); // 读取 $mm->write('user', '仅此一条'); // 覆盖 $mm->forget('user'); // 清空 $mm->clearAll(); // 清空全部作用域 $mm->forPrompt(); // 生成注入系统提示词的 块 ``` #### 记忆检索(MemoryRetriever) `forPrompt()` 会把所有作用域的记忆整段注入。记忆攒多了之后这就成了负担:几千字的历史里 可能只有两行和当前任务有关,其余全在挤占上下文。检索器把记忆按行拆成条目, 只注入与当前任务最相关的几条: ```php $retriever = $mm->retriever(); $hits = $retriever->retrieve('登录接口报 401'); // [['scope' => 'project', 'line' => 3, 'text' => '登录走 JWT,密钥在 config/jwt.php', 'score' => 62.5], ...] echo $retriever->forPrompt('登录接口报 401'); // // [project] 登录走 JWT,密钥在 config/jwt.php // $mm->retrieve('登录 401'); // 等价快捷方法 $mm->forPromptRelevant('登录 401'); // 等价快捷方法 $retriever->search('JWT', 'project'); // 纯关键词搜索,不打分 ``` 设置了 `setGoal()` 的 Agent 会自动走检索注入;没设目标时退回注入全部记忆,行为与升级前一致。 相关性是纯本地计算,不调模型:英文按词、中文按二元组切分,命中越多、覆盖查询的比例越高分越高。 这套打分**认字面不认语义**——问「鉴权」匹配不到只写了「登录」的记忆。需要语义检索时换掉打分器: ```php $retriever->setScorer(function ($query, $text) use ($ai) { return cosineSimilarity($ai->embed($query), $ai->embed($text)) * 100; }); $retriever->setTopK(3)->setMinScore(20.0); ``` **压缩与过期**——长跑任务里记忆会无限增长,定期清理比等到超出 `maxInject` 被截断更可控: ```php $retriever->compress('session', 20); // 只保留最近 20 条,返回删除条数 $retriever->expire('agent', 30); // 删除 30 天前的条目 ``` `expire()` 只处理带日期前缀的条目(`[2026-09-02] ...`),没有日期的一律保留—— 分不清写入时间就不该替用户决定它过期了。 #### 模型自主记忆(remember / forget 工具) 自 v1.65 起,`remember()` 默认给每条记忆加上**日期 + 内容散列 id** 前缀: ```text - [2026-09-03] (#9bda3a) 项目使用 CodeIgniter 3 ``` id 取内容散列(非随机/自增),因此**同一条内容重复 `remember()` 天然幂等**,不会把 记忆文件撑满。`remember($scope, $content, ['date' => false])` 可关掉日期前缀(id 仍写)。 配套的删除按 id 精确进行: ```php $mm->remember('project', '项目使用 CodeIgniter 3'); // 写入带 id 的条目 $mm->forgetById('project', '9bda3a'); // 按 id 精确删 $mm->findByPattern('project', 'CodeIgniter'); // 按子串找候选(不删) ``` 挂了记忆、且是**用工具的 Agent**(如 `codeAgent()`)时,库会自动注册 `remember` / `forget` 两个工具,让模型主动记录/删除结论。记忆写入属持久副作用,默认经 `PermissionManager` 把关(`dont_ask` / `bypass` 放行)。注入系统提示词时,长期 scope (`agent` / `user` / `project`)**常驻**、短期 scope(`session` / `task`)按目标相关性—— 即便某次检索零命中,长期记忆也不会消失。 **自动沉淀**默认关闭——自动从每次结果里提炼长期记忆比自动保存会话风险高得多, 必须显式开启: ```php $agent->autoConsolidate(true); // 每次 run()/chat() 后从结果提炼候选、去重后写入 ``` ### 检查点(CheckpointManager) 每轮迭代结束时自动保存检查点(Checkpoint),Runtime 崩溃后可从最新检查点恢复。每个检查点以 JSON 文件持久化,按任务 ID 分组。 ```php $agent->setCheckpointDir('/tmp/checkpoints'); // 或手动创建 use Ai\Agent\Checkpoint\CheckpointManager; $cm = new CheckpointManager('/tmp/checkpoints'); $cm->save('task_1', 5, $messages, ['extra' => 'data']); $latest = $cm->loadLatest('task_1'); echo $latest->getIteration(); // 5 ``` 默认保留最近 5 个检查点,超出的旧检查点自动清理。支持过期清理: ```php // 自定义保留数 $cm = new CheckpointManager('/tmp/checkpoints', [ 'maxCheckpoints' => 10, ]); // 清理超过 7 天的检查点 $cm->cleanExpired('task_1', 7); ``` ### 崩溃恢复(Crash Recovery) 从崩溃中恢复——加载最新检查点,恢复消息上下文,继续执行。 ```php $agent->setCheckpointDir('/tmp/checkpoints'); $messages = $agent->recoverFromCrash('task_1'); if ($messages !== null) { // 恢复成功,继续执行 $agent->run($messages); $text = $agent->lastText(); // run() 返回 void,文本从这里取 } else { // 无可用检查点,从头开始 $agent->run([['role' => 'user', 'content' => '请检查项目']]); } ``` `recoverFromCrash()` 内部调用 `AgentRuntime::recover()`,自动加载最新检查点、恢复迭代计数、设置任务 ID。异常时也会自动保存崩溃时的检查点,防止数据丢失。 #### 长任务恢复:不只是消息历史 小时级甚至天级的任务恢复回来,光有消息历史不够——得知道计划走到第几步、目标是什么,否则模型只能从消息里重新推断。检查点因此会一并保存运行时状态: ```php $agent->setCheckpointDir('/var/data/checkpoints'); $agent->setPlanDir('/var/data/plans'); $plan = $agent->plan('迁移数据库', ['备份', '改表', '校验']); $agent->run([['role' => 'user', 'content' => '开始迁移']]); // 中途崩了…… // 新进程恢复 $runtime = $agent->getRuntime(); $messages = $runtime->recover('task_1'); echo $runtime->getGoal(); // '迁移数据库' —— 已还原 echo $runtime->getPlan()->progress(); // 计划状态与各步骤状态原样还原 print_r($runtime->getLastCheckpoint()->getExtra()['workspace']); // ['dir' => '/var/www/project', 'branch' => 'main', 'modified' => ['db/schema.sql']] ``` 检查点里存的内容:消息历史、迭代计数、任务目标、执行计划快照(含每一步的状态)、崩溃时的工作区状态(目录 / 分支 / 已修改文件)、记忆目录。 **工作区不会被自动"恢复"**:检查点存的是崩溃那一刻工作区长什么样,而磁盘上的文件此刻可能已经不同了,照着旧快照改回去是危险操作。它作为信息交给你比对,要不要动由你决定。 ### 任务队列(AgentQueue) 将任务(Task)+ 运行时(AgentRuntime)按入队顺序排队执行,适用于 PHP-FPM 下需要后台执行的场景。 ```php use Ai\Agent\Queue\AgentQueue; $queue = new AgentQueue(); $task = $queue->dispatch('检查并修复项目', $runtime, $messages, 'sess_1'); // 逐个处理待执行任务 while ($queue->hasPending()) { $result = $queue->processNext(); // $result->getText() 获取最终回复 } // 或处理指定任务 $result = $queue->process('task_xxx'); // 任务控制 $queue->cancel('task_xxx'); // 取消 $queue->resume('task_xxx'); // 恢复暂停的任务 echo $task->getStatus(); // queued / running / completed / failed / cancelled ``` `AgentQueue` 内部使用 `TaskManager` 管理任务生命周期,自动创建或复用外部传入的 `TaskManager`。 ### 执行计划(PlanManager) 面对复杂任务时,Agent 先把目标拆成有序步骤再动手,而不是「想一步做一步」。计划带状态、依赖与修订历史,可持久化到磁盘,崩溃后继续执行。 ```php use Ai\Agent\Planning\PlanManager; $pm = new PlanManager('/var/data/plans'); // 传空字符串则只放内存 $plan = $pm->createPlan('为 Auth 模块补充单元测试', [ 'steps' => [ '阅读 src/Auth.php 理清分支', '编写 tests/AuthTest.php', '运行 phpunit 并修复失败用例', ], 'risks' => ['依赖外部 Redis,测试需要 mock'], ]); $pm->start($plan->getId()); $step = $pm->getCurrentStep($plan->getId()); // PlanStep:第 1 步 $pm->completeStep($plan->getId(), $step->getId(), '已梳理出 4 个分支'); $pm->advance($plan->getId()); // 推进到下一步 echo $plan->progress(); // 33(百分比) echo $plan->toSummary(); // 注入系统提示词用的简明摘要 ``` 步骤状态为 `pending → running → completed / failed / skipped`;计划状态为 `pending → running → completed / failed`。 **按依赖图执行**——步骤可声明依赖,`PlanExecutor` 只执行依赖已满足的步骤: ```php use Ai\Agent\Planning\PlanExecutor; $executor = new PlanExecutor($pm, ['mode' => PlanExecutor::MODE_DEPENDENCY]); $result = $executor->executeAll($plan->getId(), function ($step, $plan) { // 在这里让 Agent 真正执行这一步,返回字符串结果或抛异常表示失败 return $agent->ask($step->getAction())->getText(); }); // $result = ['completed' => 2, 'failed' => 1, 'skipped' => 0, 'status' => 'failed'] ``` **计划审查**——执行过程中偏离预期时,`PlanReview` 给出问题与建议,并可直接改计划: ```php use Ai\Agent\Planning\PlanReview; $review = new PlanReview($pm); $r = $review->review($plan->getId()); // $r = ['status' => 'affected', 'progress' => 33, 'issues' => [...], 'suggestions' => [...], ...] // 审查后追加/插入/移除步骤 $review->reviewAndAdjust($plan->getId(), [ 'append' => ['补充 README 中的测试说明'], ], '发现文档未同步'); $review->detectDependencyCycle($plan); // 返回环上的步骤 ID,无环则为空数组 ``` #### 接入 Agent 运行时 `PlanManager` 挂到 Agent 上之后,计划摘要会在每轮迭代注入系统提示词(`` 块), 模型据此知道整体走到第几步: ```php $agent->setPlanDir('/var/data/plans'); // 可选,不设则只放内存 $plan = $agent->plan('重构支付模块', [ '读懂现有 Pay.php', '拆出 PaymentGateway 接口', '跑测试确认行为不变', ]); $agent->run([['role' => 'user', 'content' => '开始重构']]); echo $agent->getPlan()->progress(); // 执行到哪一步了 ``` `plan()` 同时把目标写进 `setGoal()`——反思和记忆检索都用这个目标。 计划可以随任务状态一起落盘,崩溃恢复后接着执行: ```php $state = new \Ai\Agent\Task\TaskState(['goal' => '重构支付模块']); $state->setPlan($plan); file_put_contents('/var/data/task.json', $state->toJson()); // 恢复 $restored = \Ai\Agent\Task\TaskState::fromJson(file_get_contents('/var/data/task.json')); $plan = $restored->restorePlan(); // Plan 对象,步骤状态原样还原 ``` ### 自我反思(ReflectionManager) 工具执行完不等于目标达成。`ReflectionManager` 在每轮工具结果回填后判断「目标是否真的完成」,未完成则给出下一步行动建议,形成「执行 → 检查 → 未完成 → 继续」的闭环。 ```php use Ai\Agent\Reflection\ReflectionManager; $rm = new ReflectionManager(['maxRounds' => 5]); $result = $rm->reflect($messages, '让 phpunit 全部通过'); if ($rm->shouldContinue($result)) { echo $result->getReason(); // '最近一次工具执行报错:Fatal error ...' echo $result->getNextAction(); // '分析上述错误并修复后重新运行测试' echo $result->toPrompt(); // 直接注入下一轮 prompt 的文本 } ``` 默认判据按顺序看四条: 1. 最后一条 assistant 消息带完成标记(「任务完成」「已解决」「all done」…)→ 完成 2. **最后一批**工具结果里还有报错 → 继续修;同一个报错连撞两批则停手,继续重试没有进展 3. 一个工具都没调过、且还在首轮 → 继续(模型还停在对话阶段) 4. 其余 → 完成 第 2 条只看最后一批:早先的报错可能已经在后续轮次里修掉了,拿旧报错反复逼模型会一直空转到轮数上限。报错的判定以工具结果的 `is_error` 标志为准(库回填时一定带),只有调用方手工拼的、没有这个标志的消息才退回关键词扫描——对库产出的结果做关键词扫描会误伤,读一个正文里出现 `error` 的文件就算报错了。 第 4 条是「模型不再调用工具」这个信号本身:它比任何关键词都可靠。**不要按轮次硬逼**——回填的那句话不带任何任务信息,模型只会困惑地反问,而那句反问就成了调用方拿到的最终答案,真正的结论被顶掉。 需要模型驱动的反思时注入自定义策略: ```php $rm->setStrategy(function (array $messages, $goal) use ($ai) { $verdict = $ai->ask("目标:{$goal}\n以上执行是否已完成?回答 DONE 或说明缺什么"); return strpos($verdict, 'DONE') !== false ? \Ai\Agent\Reflection\ReflectionResult::completed('模型判定已完成') : \Ai\Agent\Reflection\ReflectionResult::continuing($verdict, '按上述说明继续'); }); ``` `maxRounds` 是兜底:**反思次数**达到上限后强制结束,避免模型在「还差一点」的判断里空转。它数的是反思做过几轮(`reflection_round`),不是 Agent 循环的迭代号(`iteration`)——两者语义不同,混用会变成「跑到第 N 轮迭代之后反思一律放行」,哪怕工具还在报错。 这道闸排在判据**之后**:自定义策略永远会被调用,只是它说「还要继续」而轮次已到顶时,由上限收口。(排在前面会让注入的策略在轮次超限后根本不执行,排查时只会怀疑自己的策略写错了。) #### 接入 Agent 循环 挂到 Agent 上之后,反思发生在「模型不再调用工具、准备收工」的那一刻:目标没达成就把 下一步建议作为 user 消息回填,驱动模型继续干,而不是就此结束。 ```php $agent->enableReflection(['maxRounds' => 5]); $agent->setGoal('让 composer test 全部通过'); $agent->onEvent(function ($e) { if ($e['type'] === 'reflection') { echo $e['success'] ? "反思:目标已达成\n" : "反思:{$e['reason']}\n"; } }); $agent->run([['role' => 'user', 'content' => '修一下失败的测试']]); ``` 没调用 `enableReflection()` 时循环行为与升级前完全一致——不会平白多跑几轮。 `setGoal()` 未设置时,目标退回取首条用户消息。 ### 开发者 API:三样东西就能开工 用户不需要理解 LoopController / ContextManager / PermissionManager / SubAgentManager——提供 AI、workdir、Prompt 三样东西即可: ```php use Ai\Agent\Agent; $agent = Agent::create($ai) ->workdir('/var/www/project') ->codeAgent(); $result = $agent->task('把这个项目的登录系统改造成支持 Google OAuth,并完成测试'); print_r($result->toContract()); // [ // 'status' => 'completed', // 'summary' => '已接入 Google OAuth 并补充测试', // 'files_changed' => ['src/Auth.php', 'tests/AuthTest.php'], // 'tests' => ['passed' => true], // 'verification' => ['passed' => true], // 'artifacts' => ['artifact://task_1/test-report.json'], // 'cost' => 0.042, 'iterations' => 14, 'duration_ms' => 68210.5, // ] ``` 链式配置: ```php $agent = Agent::create($ai) ->workdir(__DIR__) ->codeAgent(['test' => 'composer test']) ->tools(['my_tool' => $myTool]) // 追加工具(不覆盖已有的) ->skills('/path/to/skills') // 装载技能 ->agents(['dba' => [ // 注册自定义子 Agent 'description' => '数据库结构与索引优化', 'tools' => ['read_file', 'bash'], // 写工具名即可,从父工具集里挑 ]]); $result = $agent->task('修复登录 Bug'); // 编排层自己选策略 $handle = $agent->dispatch('扫描整个项目'); // 后台执行,立即返回 task_id $agent->resume($handle['task_id']); // 查后台句柄,或从检查点恢复 ``` `AgentResult` 的结构化契约:`getStatus()` / `getSummary()` / `getFilesChanged()` / `getTests()` / `getVerification()` / `getArtifacts()` / `getSubtasks()` / `getCost()` / `getDuration()`,`toContract()` 一次拿全,可直接 JSON 返回给调用方。字段由调用方或 `WorkspaceSnapshot` 填入——`AgentResult` 自己不去扫工作区,那是 Workspace 的职责。 ### 工具分组与按需发现 工具多了不该一股脑全发给模型:几十个工具定义塞进每一轮请求,既占 token 又让模型更难选对。 ```php $agent->toolGroups()->disable(\Ai\Agent\Tool\ToolGroup::DEPLOYMENT); // 这次任务不许碰部署 $agent->toolGroups()->only([\Ai\Agent\Tool\ToolGroup::FILESYSTEM]); // 只开文件操作 ``` 内置分组:filesystem / git / database / network / browser / cloud / testing / deployment。**没归过组的工具默认可用**——分组用来收窄,不该因为忘了归类就让工具消失。工具属于多个分组时,任一分组启用即可用。 按需发现则反过来:初始只给一小撮常用工具 + 一个 `search_tools`,模型需要别的能力时自己搜: ```php $agent->useToolDiscovery(['read_file', 'grep', 'glob']); // 模型调用:search_tools(query: "database") // → 找到 sql_query / db_schema,自动启用,之后可直接调用 ``` 搜索是纯本地的关键词匹配(工具名 + 描述),不调模型——为了省 token 而多花一次模型调用不划算。 收窄的是「**给模型看什么**」,不是「能执行什么」:工具注册表始终保有全量工具(否则搜出来激活了也执行不了),每轮真正发给模型的工具定义由激活集过滤。所以模型这一轮 `search_tools` 激活的工具,**下一轮就能看见并直接调用**。 ### 分层权限策略 权限来自四个层面,关系是**取交集**而不是取并集,并且 **DENY 优先**: ```text 最终权限 = Global AND Agent AND Skill AND Task ``` ```php $policy = $agent->permissionPolicy(); $policy->layer('global') ->allow('Bash(git *)') ->deny('Bash(rm -rf *)') ->deny('Write(.env)'); $policy->layer('task')->allow('Bash(php *)'); $agent->applyPermissionPolicy(); $policy->check('Bash', 'git status'); // 'allow' $policy->check('Bash', 'rm -rf /'); // 'deny' $policy->check('Bash', 'curl x.com'); // 'ask' —— 没人明确允许 $policy->explain('Bash', 'rm -rf /'); // ['decision' => 'deny', 'layer' => 'global', 'rule' => 'Bash(rm -rf *)'] ``` 顺序不能反过来:如果允许下层放宽上层,那么一个被 Skill 声明允许的 `Bash(rm -rf *)` 就能绕过全局禁令。任务结束时 `clearLayer('task')`,层与层之间不该互相污染。 ### 事件回放(EventLog) 前端断线重连时要的是「从我收到的最后一条之后继续发」,**只重发事件,不重新执行 Agent**——重跑会产生新的副作用(再改一遍文件、再跑一遍命令),而客户端要的只是补上漏掉的那几条。 ```php $log = $agent->eventLog('/var/data/events'); // 创建后自动接上事件回调 // 客户端带着 Last-Event-ID 重连 $missed = $log->sinceId($lastEventId); echo EventLog::toSse($missed); // 直接输出 SSE 帧 $log->since(42); // 按 sequence 补发 $log->ofTask('task_1'); // 某个任务的全部事件 $log->ofType('tool_call'); // 按类型过滤 $log->lastSequence(); // 当前最新序号 ``` 事件 ID 找不到时返回全部——宁可重发也不要漏发,客户端自己去重比丢消息强。不传目录则只在内存里;**断线重连场景必须配目录**,重连时进程可能已经换了。 ### 调度与并发上限 ```php $scheduler = $agent->scheduler([ 'max_tasks' => 50, 'max_concurrent' => 4, 'max_subagents' => 4, 'max_background_agents' => 2, 'max_parallel_tools' => 8, 'maxRetries' => 2, ]); $scheduler->submit('task_1', '扫描安全问题', AgentScheduler::PRIORITY_HIGH); $scheduler->submit('task_2', '更新文档', AgentScheduler::PRIORITY_LOW); $next = $scheduler->next(); // 'task_1' —— 高优先级先跑 $scheduler->start($next); $scheduler->finish($next, false); // 失败 → 自动重新入队(还有重试次数时) ``` 优先级:critical / high / normal / low,同优先级按提交顺序。**一个 PHP 请求不该产生几十个 Agent**——每个子 Agent 都是独立的模型调用与上下文,失控的并发既烧钱又会把内存吃满,所以默认上限都很保守。 挂上 `TaskGraph` 之后只调度依赖已满足的任务。失败自动重试是因为瞬时故障(网络抖动、模型超时)重试一次多半就好了,让人手动重投没有意义。 ### 模型路由与产物管理 让 explorer 用最强的模型去 grep 代码是浪费,让 coder 用最便宜的模型去改架构是省小钱花大钱: ```php $router = $agent->modelRouter([ 'cheap' => 'claude-haiku-4-5-20251001', 'standard' => 'claude-sonnet-5', 'premium' => 'claude-opus-5', ]); $router->route(['agent' => 'explorer']); // cheap $router->route(['agent' => 'coder']); // premium $router->route(['task' => '重构整个认证系统']); // premium(复杂度高) $router->route(['agent' => 'coder', 'budget_left' => 0.05]); // cheap(预算见底,先把任务跑完) ``` **不配置模型名就不路由**:返回空串,调用方沿用当前模型。硬塞一个猜的模型名,换来的是运行时报「模型不存在」。 路由器创建后会自动接到子 Agent 管理器上:**定义里没写 `model` 的角色**由它选,写死了 `model` 的以定义为准——显式配置不该被启发式覆盖。选出来的模型跨平台时,凭据从 `platforms()` 登记的表里按模型名自动匹配: ```php $agent->platforms([ 'deepseek' => ['api_key' => $deepseekKey], 'moonshot' => ['api_key' => $moonshotKey], 'openai' => ['api_key' => $openaiKey], ])->modelRouter([ 'cheap' => 'deepseek-chat', // explorer 之类的杂活 'standard' => 'moonshot-v1-32k', 'premium' => 'gpt-4o', // coder / reviewer ]); ``` `budget_left` / `priority` 这些只有调用方知道的值,用 `setRouteContext()` 补进去(传数组是固定值,传闭包则每次路由前取实时值): ```php $agent->getRuntime()->getSubAgentManager()->setRouteContext(function ($def, $task) use ($budget) { return ['budget_left' => $budget->remainingRatio()]; }); ``` 产物(测试报告、补丁、日志、分析结果)不该全文塞进上下文——一份 5000 行的报告扔进去,剩下的对话就没地方了: ```php $artifacts = $agent->artifacts('/var/data/artifacts'); $ref = $artifacts->put('task_123', 'test-report.json', $reportJson); // 'artifact://task_123/test-report.json' $artifacts->preview($ref, 500); // 只给模型看开头 $artifacts->get($ref); // 需要细节时再取全文 $artifacts->listFor('task_123'); // 这个任务产出了什么 $artifacts->summarize('task_1', 'out.txt', $output, 5); // '已保存到 artifact://task_1/out.txt(12.4 KB):\nFAILURES!\n…' ``` 产物名会做路径穿越防护,`../../../etc/passwd` 这类写法进不来。 ### 模型驱动:让模型自己拆计划、自己派子 Agent 编排层用规则在**模型看到任务之前**决定策略——描述里出现「重构」就先拆计划,撞上某个子 Agent 的 description 就派给它。规则跑得快、可复现,但它看不懂任务:真正该委派的探索识别不出来,碰巧撞词的简单改动反而被派走。 `modelDrivenTools()` 把这两件事做成**工具**,交给模型在循环里自己决定: ```php $agent = Agent::create($ai) ->workdir(__DIR__) ->tools($tools) ->setPlanManager(new PlanManager()) ->setSubAgentManager($sam) ->modelDrivenTools(); // ← 挂上 update_plan 与 delegate $agent->run([['role' => 'user', 'content' => '给项目加上 OAuth 登录并跑通测试']]); ``` `codeAgent()` 会自动挂上这两个工具(它本来就装了 PlanManager 和六个子 Agent),传 `['noModelDrivenTools' => true]` 可以关掉。 | 工具 | 模型用它做什么 | |---|---| | `update_plan` | 写下打算怎么做、每完成一步标成 `completed`、发现原计划不对就整表改掉 | | `delegate` | 把需要翻很多文件才能得出一个结论的探索,交给子 Agent 在**独立上下文**里做,只收回摘要 | **计划是状态,不是脚本。** 关键差别在于模型能在拿到真实结果之后改主意——读了三个文件发现项目里已经有现成的 `AuthProvider` 基础类,就直接把计划改掉,而不是照着开工前定死的那张表继续走: ```php $agent->onEvent(function ($e) { if ($e['type'] === 'plan_updated') { echo "计划改到第 {$e['version']} 版,共 {$e['steps']} 步,进度 {$e['progress']}%\n"; } }); ``` 每次覆写前的版本都会被快照保留(`PlanManager::versions()`)——「原计划是什么、为什么改成现在这样」是排查 Agent 走偏的关键线索,直接覆盖就查不到了。想在自己的代码里做同样的整表覆写,用 `PlanManager::rewrite($planId, $steps, $reason)`。 几条边界: - 两个工具都**不会**进子 Agent 的工具集。没声明 `tools` 的子 Agent 会继承父工具集的全部,拿到 `delegate` 就能再派子 Agent,一层套一层没有底。 - 依赖没挂就不注册:没有 `PlanManager` 就没有 `update_plan`,没有子 Agent 就没有 `delegate`。注册一个必然报错的工具只会让模型白调一轮。 - 委派次数上限(默认 8 次)是**每次运行**的预算,`run()` / `task()` 各自开头重置。 - 不调用 `modelDrivenTools()`、也不走 `codeAgent()` 的代码,工具集与行为完全不变。 ### 默认系统提示词 `codeAgent()` 会装上一份默认系统提示词(`CodeAgentPrompt`),告诉模型这些工具**什么时候**该用——工具描述只说明「这个工具做什么」,回答不了「什么时候该用它」。 自己调过 `setSystem()` 就不会被覆盖: ```php $agent = Agent::create($ai)->workdir($dir)->codeAgent(); // 用默认提示词 $agent = Agent::create($ai)->setSystem('...')->codeAgent(); // 用你自己的 echo \Ai\Agent\CodeAgentPrompt::build(['test' => 'composer test']); // 看看默认写了什么 ``` 它讲四件事:动手前先读懂现状;改**已有**文件一律用 `edit_file` 增量改(`write_file` 只用于新建);三件以上的任务先用 `update_plan` 写步骤;改完必须实际验证。 这份提示词是实测调出来的。同一个任务(给多文件项目加三个功能并补测试)在真网关上的对比: | | 无提示词 | 有提示词 | 加「批量编辑」一句后 | |---|---|---|---| | 轮数 | 6 | 13 | 9 | | 成本 | $0.032 | $0.067 | $0.053 | | `update_plan` 调用 | 0 次 | 3 次 | 2 次 | | 改已有文件的方式 | `write_file` 整体重写 ×3 | `edit_file` ×6 | `edit_file` ×5(并列发出) | | 测试结果 | ALL PASS | ALL PASS | ALL PASS | 三次产出都正确,差别在**过程安全性与成本**。无提示词那次用 `write_file` 整体重写了三个已有文件——这次文件小、重写对了,但在几百行的文件上,整体重写正是静默丢代码的经典路径。默认提示词用大约 60% 的成本增量换掉这个风险;补上「想清楚的改动一次全部发出」之后,增量降到约 40%。 嫌贵可以自己换一份更短的提示词,或者干脆 `setSystem('')`——但那等于回到「模型全靠猜」的状态。 ### 编排层(AgentOrchestrator) `AgentRuntime` 回答的是「怎么跑一轮循环」,编排层回答的是**「这活该怎么干」**——直接调工具、先拆计划、派给子 Agent、并行铺开,还是丢后台。 ```php $agent = (new Agent($ai))->setWorkdir('/var/www/project')->codeAgent(); $result = $agent->task('分析项目中的认证、支付、SEO'); // 自动识别成三路并行 → 派 explorer 分头调查 → 汇总摘要 echo $agent->orchestrator()->lastDecision()->toSummary(); // 策略:parallel —— 识别到 3 个互不相关的子任务 ``` `codeAgent()` 一次装齐:内置工具、六个专职子 Agent、默认验证器、执行计划、反思、项目指令。之后一句 `task()` 就能开工。 七种策略: | 策略 | 何时选中 | 典型任务 | |---|---|---| | `direct` | 范围明确 | 「读一下 README」 | | `plan` | 范围大、含重构/迁移类动词、或带编号步骤 | 「重构整个用户认证系统」 | | `delegate` | 与某个子 Agent 的职责匹配 | 「审查 Auth.php 的安全问题」→ reviewer | | `parallel` | 识别到并列的多个子任务 | 「分析认证、支付、SEO」 | | `background` | 描述里明确要求后台 | 「后台扫描整个项目」 | | `ask_user` | 任务描述为空或无法判断 | —— | | `verify` | 只是要确认既有改动 | 「跑一下测试确认没问题」 | 默认的策略选择是**基于关键词**的,读不懂任务:「顺便重构一下措辞」会被判成大型重构。`useModelStrategy()` 把这一步也交给模型: ```php $agent->useModelStrategy(); // 用当前模型决策 $agent->useModelStrategy(['model' => 'gpt-4o-mini']); // 决策用便宜的小模型 ``` 这是一次小而便宜的模型调用:只要一段 JSON,不给工具、不进 Agent 循环,同一个任务描述会缓存。模型拿不准、返回的不是 JSON、或者调用失败时**自动退回规则版**——决策失败不该是任务失败。模型点名一个不存在的子 Agent 时降级成 `direct` 自己干,而不是硬派出去。 **决策一定会进事件流**(`strategy_decision` 事件,带 `reason`)。Agent 自主选策略之后,使用者必须能回答「它为什么这么干」,否则出了问题只能靠猜: ```php $agent->onEvent(function ($e) { if ($e['type'] === 'strategy_decision') { echo "{$e['strategy']}:{$e['reason']}\n"; } }); ``` 决策与执行是分开的,可以先看再决定要不要照做: ```php $decision = $agent->orchestrator()->decide('重构认证系统'); if ($decision->is(\Ai\Agent\Orchestrator\ExecutionStrategy::PLAN)) { $result = $agent->orchestrator()->execute('重构认证系统', $decision); } ``` 默认选择器是**基于规则**的,不额外调模型——多花一次模型调用去决定「要不要多花模型调用」不划算,而且规则版的决策可复现、出问题能查。拿不准时一律退回 `direct`:保守执行的代价是多跑几轮工具,错误委派的代价是一个子 Agent 带着错误上下文跑十几轮。 需要模型驱动时换掉它: ```php $agent->orchestrator()->selector()->setResolver(function ($task, $context) use ($ai) { // 返回 StrategyDecision;返回 null 则退回规则判断 }); // 或直接关掉某类自动行为 $agent->orchestrator()->selector()->setAutoDelegate(false)->setAutoPlan(false); ``` **不挂子 Agent 与计划管理器时,所有策略都退回 `direct`**,跑出来跟直接调 `run()` 一样——编排层是加法,不改变既有行为。 ### 内置子 Agent(BuiltinAgents) 六个开箱即用的专职角色。它们的价值主要不在提示词,而在**工具集是收窄的**:explorer 拿不到写文件的工具,所以它不可能在「调查代码」的过程中顺手改掉什么。这比在提示词里写「请不要修改代码」可靠得多。 | 角色 | 用途 | 工具 | |---|---|---| | `explorer` | 代码搜索、阅读、调查、依赖分析 | read_file / grep / glob | | `planner` | 执行计划生成与任务拆解 | read_file / grep / glob | | `coder` | 代码修改 | read_file / write_file / edit_file / grep / glob / bash | | `tester` | 运行测试、分析失败 | read_file / grep / glob / bash | | `reviewer` | 代码审查、安全审查 | read_file / grep / glob / bash | | `debugger` | 错误分析、问题定位 | read_file / grep / glob / bash | ```php use Ai\Agent\SubAgent\BuiltinAgents; BuiltinAgents::registerAll($sam); // 六个全装 BuiltinAgents::register($sam, ['explorer', 'tester']); // 只装两个 BuiltinAgents::isReadOnly('explorer'); // true ``` ### 子 Agent 完整配置 ```php $sam->register('reviewer', [ 'description' => '代码审查与安全审查', // 也是自动委派的匹配依据 'prompt' => '你是代码审查者……', 'tools' => [...], 'disallowedTools' => ['write_file', 'edit_file'], 'model' => 'claude-sonnet-5', // 该角色单独用的模型 'api_key' => $anthropicKey, // 该角色单独用的平台凭据(见「跨平台编排」) 'permissionMode' => 'manual', 'maxTurns' => 15, 'skills' => ['php-development'], 'mcpServers' => ['fs'], 'hooks' => $hooks, 'memory' => '/var/data/memory/reviewer', 'background' => false, 'isolation' => 'worktree', ]); ``` **子 Agent 的能力永远是父 Agent 的子集。** 这条是硬约束,不是建议: ```php $sam->setParentTools($parentTools); // 父 Agent 只有 read_file / grep / glob $sam->register('greedy', ['tools' => ['read_file' => …, 'write_file' => …, 'bash' => …]]); $sam->resolveTools($sam->get('greedy')); // 只剩 read_file —— 父没有的拿不到 ``` 三步收窄,每一步都只会让工具变少:起点是子 Agent 声明的 `tools`(没声明则用父全集)→ 与父工具集求交 → 去掉 `disallowedTools`。`permissionMode` 同理,只能往严格方向调:`bypass` 的父 Agent 下面可以挂 `manual` 的子 Agent,反过来不行。 `model` 与连接信息由一个**独立的 AI 实例**承载(从父实例 clone 而来),父 Agent 的模型与凭据不会被改动,详见下一节。 定义里声明的 `background` / `isolation` 是**下限**:工具入参可以额外开启,但不能关闭。一个被配置成必须 worktree 隔离的子 Agent,不该因为模型没传参就直接改到父工作区。 ### 跨平台编排:不同角色跑在不同平台 用 DeepSeek 写代码、Kimi 审代码、GPT 做规划——三家平台、三把 Key、三个地址,写在一个 Agent 里: ```php $agent = Agent::create($ai)->agents([ 'planner' => ['description' => '任务规划', 'model' => 'gpt-4o', 'api_key' => $openaiKey], 'coder' => ['description' => '写代码', 'model' => 'deepseek-chat', 'api_key' => $deepseekKey], 'reviewer' => ['description' => '代码审查', 'model' => 'moonshot-v1-32k', 'api_key' => $moonshotKey], ]); ``` 角色多了不必逐个贴 Key——用 `platforms()` 按平台登记一次,库按模型名推断平台后自动配对: ```php $agent->platforms([ 'deepseek' => ['api_key' => $deepseekKey], 'moonshot' => ['api_key' => $moonshotKey], 'openai' => ['api_key' => $openaiKey], ])->agents([ 'coder' => ['description' => '写代码', 'model' => 'deepseek-chat'], 'reviewer' => ['description' => '代码审查', 'model' => 'moonshot-v1-32k'], 'planner' => ['description' => '任务规划', 'model' => 'gpt-4o'], ]); ``` 表的键可以是平台名(同 `AI::platformOfModel()` 的取值),也可以是**具体模型名**——模型名精确匹配优先,用于同平台不同模型要走不同网关的情况。已经有配好的 `AI` 实例时直接给 `'ai' => $kimiAi`,优先级最高。 可写的连接键:`api_key`、`base_url`、`endpoint`、`endpoint_models`、`protocol`、`platform`、`headers`、`organization`、`project_id`、`extra_body`,也可以整块塞进 `connection`。 三条规则,都是为了避免「静默打错平台」: 1. **写了任何一个连接键,父 Agent 的连接信息就整份不再继承。** 半继承最危险——留下父 Agent 的 `base_url`,Kimi 的模型名会被发到 DeepSeek 的地址上,报回来的是「模型不存在」,没人会往连接信息上想。 2. **一个连接键都不写时行为与旧版一致**:完全沿用父 Agent 的连接,只换模型名。OpenRouter / 自建网关那种「一把 Key 打所有模型」的用法不受影响。 3. **`temperature`、`max_tokens` 这类生成参数照常从父 Agent 继承**,它们与「打到哪个平台」无关。 子 Agent 拿到的是独立 AI 实例(父实例 clone 而来,注入的 transport 与流式回调跟着走),所以:跑完父 Agent 的模型与 Key 原样不动,并行铺开时几个角色也不会互相踩。凭据不会进 transcript——`SubAgentDefinition::toArray()` 只列连接的**键名**,不含值。 完整可运行示例见 `examples_multi_platform.php`。 ### 任务依赖图(TaskGraph) 父子关系(`parentTaskId`)表达的是从属,依赖表达的是**顺序**——两个兄弟任务可以同属一个父任务,却一个必须等另一个跑完。这两件事必须分开表达,否则「B 与 C 并行、D 等 C」这种结构根本写不出来。 ```php use Ai\Agent\Task\TaskGraph; $graph = new TaskGraph(); $graph->addTask('a')->addTask('b')->addTask('c')->addTask('d'); $graph->dependsOn('b', 'a'); $graph->dependsOn('c', 'a'); $graph->dependsOn('d', 'c'); $graph->ready(); // ['a'] $graph->markCompleted('a'); $graph->ready(); // ['b', 'c'] —— 可并行 $graph->markCompleted('c'); $graph->ready(); // ['b', 'd'] $graph->layers(); // 按依赖深度分层,同层可并行 $graph->dependentsOf('c'); // ['d'] —— 改 c 会影响谁 ``` **失败会向下游传播成 `blocked`**:让一个注定跑不起来的任务留在队列里等,只会浪费一次调度。 ```php $graph->markFailed('x'); $graph->getStatus('y'); // 'blocked'(y 硬依赖 x) $graph->blocked(); // ['y', 'z'] —— 传递阻塞 ``` 软依赖只约束顺序,上游失败也照跑: ```php $graph->dependsOn('q', 'p', \Ai\Agent\Task\TaskDependency::TYPE_SOFT); ``` **会成环的依赖直接被拒绝**(`dependsOn()` 返回 `false`)——环意味着谁都跑不起来,建图时报错比运行时死锁好查得多。自依赖同理。 与 `TaskManager` 打通: ```php $graph->syncFrom($taskManager); // 同步任务与状态,图结构不变 ``` ### 后台与并行执行 PHP 没有内置事件循环,「后台执行」在不同部署环境里能做到的程度差别很大。本库把三档路径收在一处,并且**如实告知走的是哪一档**——悄悄退化成同步执行、却让调用方以为是异步的,比直接说做不到危险得多。 ```php $handle = $agent->dispatch('扫描整个项目的安全问题'); // ['task_id' => 'task_1_ab12cd34', 'status' => 'running', 'mode' => 'fork', 'background' => true] $agent->taskStatus($handle['task_id']); ``` | 档位 | 条件 | 行为 | |---|---|---| | `runner` | 注入了 runner(Swoole / Workerman 协程、队列 Worker) | 真异步,立即返回 | | `fork` | `pcntl_fork` 可用且未被 `disable_functions` 禁用 | fork 子进程,父进程立即返回 | | `sync` | 都不可用 | 同步跑完再返回,但仍返回 task_id,状态机一致 | ```php use Ai\Agent\Orchestrator\BackgroundDispatcher; $dispatcher = new BackgroundDispatcher([ 'resultDir' => '/var/data/bg', // fork 档必须配:子进程改的内存父进程看不到 'runner' => function (array $payload) { /* 投递到队列 */ return null; }, ]); $agent->orchestrator()->setDispatcher($dispatcher); ``` fork 档的结果只能靠落盘传回来,所以 `resultDir` 不配就取不到结果。runner 如果同步返回了结果,句柄会如实标成 `completed` 而不是假装还在跑。 **并行子 Agent** 同样是三档降级(`runner` / `fork` / `sequential`): ```php use Ai\Agent\Orchestrator\ParallelAgentExecutor; $executor = new ParallelAgentExecutor($sam, ['maxConcurrency' => 4]); $results = $executor->run([ ['agent' => 'explorer', 'task' => '分析认证模块'], ['agent' => 'explorer', 'task' => '分析支付模块'], ['agent' => 'explorer', 'task' => '分析 SEO 相关代码'], ]); echo $executor->mode(); // 'runner' | 'fork' | 'sequential' ``` 顺序降级档的结果与并行档完全一致,只是不省时间。注入的 runner 返回条数对不上时会退回顺序执行——宁可慢,也不能把结果与任务错位对应。 `maxConcurrency` 默认 4:一个 PHP 请求不该产生几十个 Agent。 ### 结果聚合(ResultAggregator) 并行派出去三个 explorer,回来三份几千字的报告。**主 Agent 默认只该收到摘要**,完整内容留在各自的 transcript 里按需查——否则并行省下的时间,全赔在被污染的上下文里了。 ```php $summary = $agent->orchestrator()->aggregator()->aggregate($results); $summary['summary']; // 合并摘要(给主 Agent 的就是这个) $summary['findings']; // 各路结论(已按长度截断) $summary['files']; // 提到的文件,去重 $summary['errors']; // 未完成的那几路,带原因 $summary['recommendations']; // 抽出来的建议 $summary['transcripts']; // task_id 列表,要看细节按这个查 $summary['stats']; // ['total' => 3, 'completed' => 2, 'failed' => 1] ``` 默认是规则聚合(截断 + 归类 + 去重),不调模型。需要更好的摘要时注入模型摘要器——但那要多花一次调用,默认不替使用者做这个决定: ```php $aggregator->setSummarizer(function (array $results) use ($ai) { return $ai->ask('把以下多路调查结果合并成一段摘要:' . json_encode($results)); }); ``` 摘要器抛异常时会退回规则拼接,不会因为摘要挂了把结果整个丢掉。 ### 子 Agent transcript 落盘与续跑 ```php $sam->setTranscriptDir('/var/data/transcripts'); $runId = $sam->runSync('explorer', '调查登录流程'); // 另一个进程也读得到 $sam2->setTranscriptDir('/var/data/transcripts'); $sam2->getTranscript($runId); // 子 Agent 跑到一半被截断(迭代上限、权限被拒)时接着干,而不是从头再来 $newRunId = $sam->resume($runId, '继续看看权限校验'); $sam->getTranscript($newRunId)['resumed_from']; // 原 runId ``` 不配 `transcriptDir` 时 transcript 只在内存里,进程结束即丢——后台任务与崩溃恢复都需要它能被另一个进程读到,所以长任务场景必须配。 ### 验证闸门与完成判据 **不能因为模型说「完成了」就算完成。** 模型判断自己是否达成目标是出了名的乐观:测试还红着、计划还剩三步、上一次工具调用还在报错,它照样会说「已完成」。 ```php $agent->useVerificationGate(); // 按任务类型自动选验证链 $agent->setCompletionCriteria([ // 完成 = 一组可检查的条件 \Ai\Agent\Orchestrator\CompletionCriteria::VERIFICATION_PASSED, \Ai\Agent\Orchestrator\CompletionCriteria::NO_PENDING_STEPS, \Ai\Agent\Orchestrator\CompletionCriteria::NO_PENDING_ERRORS, ]); $result = $agent->task('修复登录 401'); $outcome = $agent->checkCompletion($result, '修复登录 401'); if (!$outcome['completed']) { echo $outcome['prompt']; // 任务尚未达成完成条件: // - 验证未通过 // - 计划里还有 2 个步骤未完成:跑测试、更新文档 $agent->ask($outcome['prompt']); // 带着原因继续干 } ``` 闸门按任务类型选不同的验证链——一套验证跑遍所有任务,要么太松(漏掉该验的)要么太紧(改个错别字也跑全量集成测试): | 任务类型 | 验证链 | 识别关键词 | |---|---|---| | `bug_fix` | 语法 → 单元测试 | 修复 / 报错 / bug / fix | | `feature` | 语法 → 安全 → 单元测试 | 新增 / 实现 / 支持 / implement | | `refactor` | 语法 → 测试 → 改动规模检查 | 重构 / 重写 / refactor | | `security` | 语法 → **安全(必过)** → 测试 | 安全 / 漏洞 / 注入 / injection | | `default` | 语法 + 安全 | 认不出来时用它 | ```php use Ai\Agent\Verification\VerificationGate; use Ai\Agent\Verification\VerificationPolicy; $gate = new VerificationGate($vm, VerificationPolicy::security()); $outcome = $gate->check(['file_path' => 'src/Auth.php']); $outcome['passed']; // 闸门放行了吗 $outcome['failed']; // 哪几步没过,required 标明是不是必过项 $outcome['skipped']; // 策略里写了但没挂验证器的步骤 $outcome['prompt']; // 可直接回填给模型的失败说明,带文件与行号 ``` 策略里写了但没挂对应验证器的步骤会被**跳过并记录**,不会让整条链卡住。`failFast` 默认开启:必过项一失败就停,不再白跑后面的。 四条内置判据:`verification_passed`(**没跑过验证 = 未满足**,说「没验证所以算通过」就等于没有这道闸门)、`no_pending_steps`、`no_pending_errors`、`model_claims_done`。也可以注册自定义判据: ```php $criteria->addCriterion('has_changelog', function (array $context) { return ['met' => !empty($context['changelog']), 'reason' => '还没写 changelog']; }); ``` 预设:`CompletionCriteria::lenient()`(只看有没有报错,适合没测试没计划的轻量任务)、`::strict()`(四条全要)。 ### 任务交接(AgentHandoff) Coder 改到一半发现是数据库结构的问题,把任务转给 DBA;后者处理完再转回来。**交接必须留痕**——否则一个任务在几个角色之间转了几圈之后,没人说得清它经历过什么。 ```php $handoff = $team->handoff('developer', 'dba', '慢查询定位到索引缺失', [ 'task_id' => 'task_9', 'context_summary' => '已定位到 UserRepo::findByEmail,全表扫描 12 万行', ]); // DBA 处理完交回去 $team->handoffBack($handoff, '索引已补,慢查询从 3s 降到 20ms'); $team->handoffChain('task_9'); // ['developer → dba', 'dba → developer'] ``` 交接会自动向接手方投递一条 `handoff` 消息,对方下次被分派任务时,收件箱里就带着「谁交给我的、为什么、进展到哪」。 消息类型也补齐到九种:原有的 `task` / `bug` / `review` / `status` / `result`,新增 `request` / `response` / `error` / `handoff`。回应会自动带上 `reply_to`——一个角色同时问了三件事时,没有这个字段就分不清答复对应哪个问题: ```php $req = AgentMessage::request('coder', 'dba', '这张表有索引吗'); $resp = AgentMessage::respondTo($req, '没有,需要补'); $resp->getReplyTo(); // $req 的 ID ``` ### 跨 Session 消息(SessionBus) `AgentCommunication` 管的是一个团队内部、同一个进程里的消息。跨 Session 是另一回事:后台 Agent 在另一个进程(甚至另一次 PHP 请求)里跑完,得让主 Session 知道。**那条消息必须落盘才传得过去**——内存里的队列,另一个进程根本看不见。 ```php // 后台 Agent 那边 $bus = new \Ai\Agent\Session\SessionBus('/var/data/session-bus'); $bus->send('session_main', AgentMessage::status('background', '安全扫描完成,发现 3 个问题')); // 主 Session 这边 $bus = $agent->sessionBus('/var/data/session-bus'); $bus->pendingCount('session_main'); // 1 echo $bus->toPrompt('session_main'); // 块,可直接注入 $bus->receive('session_main'); // 收完即清空 ``` 订阅回调只在**本进程内** `send()` 时触发——跨进程投递的消息,另一端得靠 `receive()` 主动取。PHP 没有常驻的进程间推送通道,这一点无法回避。 不传目录时退化成纯内存模式,只在同进程内可用;**后台任务通知必须配目录**,否则消息发出去没人收得到。 ### 计划版本链 `modifyPlan()` 不再原地覆盖,而是产生新版本。**「原计划是什么、为什么改成现在这样」是排查 Agent 走偏的关键线索**,直接覆盖就查不到了。 ```php $plan = $pm->createPlan('迁移数据库', ['steps' => ['备份', '改表']]); $plan->getVersionLabel(); // 'plan_v1' $pm->modifyPlan($plan->getId(), ['append' => ['校验数据']], '发现漏了校验'); $pm->getPlan($plan->getId())->getVersion(); // 2 $pm->versions($plan->getId()); // 历史版本快照 $pm->getVersion($plan->getId(), 1); // 取回 v1,仍然是两步 $pm->diffVersions($plan->getId(), 1, 2); // ['added' => ['校验数据'], 'removed' => [], 'reason' => '发现漏了校验'] ``` ### 工作区快照(WorkspaceSnapshot) 任务开始拍一张,结束再拍一张,两张一比就知道这个任务到底改了什么——而不是听模型自己报「我改了 Auth.php」。模型漏报和多报都很常见,尤其在它中途改了主意又改回去的时候。 ```php use Ai\Agent\Workspace\WorkspaceSnapshot; $before = WorkspaceSnapshot::capture('/var/www/project'); // …Agent 干活… $after = WorkspaceSnapshot::capture('/var/www/project'); $diff = WorkspaceSnapshot::diff($before, $after); $diff['added']; // 新增的文件 $diff['modified']; // 改动的文件 $diff['deleted']; // 消失的文件 $diff['branch_changed']; // 分支变了没有 $diff['content_changed']; // diff 哈希变了没有 ``` 快照内容:cwd、分支、commit、已修改文件、未跟踪文件、工作区 diff 哈希。不是 git 仓库时快照仍然可用,只是 branch / commit / diff_hash 为空——能记的记下来,记不了的留空,不猜。 ### Worktree 收尾:合入或丢弃 子 Agent 在隔离的 worktree 里改完、diff 拿回来了,接下来要么合入要么丢弃: ```php $samW->mergeWorktreeRun($runId, true); // 先试打(git apply --check) $samW->mergeWorktreeRun($runId); // 真打 // ['applied' => true, 'reason' => 'applied'] $samW->discardWorktreeRun($runId, '方案不对'); // 留痕:这份 diff 被看过并否决了 ``` 用 `git apply` 打补丁而不是 merge 分支——worktree 里的改动通常没提交,没有 commit 可合。 ### Skill 生命周期与依赖 ```php $sm->onEvent(function ($e) { echo $e['type'], "\n"; }); // skill_discovered / skill_loaded / skill_activated / skill_deactivated $sm->deactivate('deploy'); // 停用:后续轮次不再注入,allowed-tools 一并重算 ``` SKILL.md 多认两个字段: ```markdown --- name: deploy required-tools: - bash - ssh dependencies: - docker --- ``` ```php $sm->checkRequirements('deploy', ['bash']); // ['satisfied' => false, 'missing' => ['ssh', 'skill:docker']] ``` `required_tools` 里的工具当前拿不到时,这个技能加载了也用不了——与其让模型按技能指示去调一个不存在的工具,不如提前说清楚。 ### 指令就近发现 ```php $im->setProjectRoot('/var/www/project'); $im->discoverFor('/var/www/project/src/Admin/User.php'); // 依次加载:project/CLAUDE.md → project/src/AGENTS.md → project/src/Admin/AI.md ``` 从文件所在目录向上找到项目根,沿途的指令文件按「远的在前、近的在后」加载——**离当前文件最近的规则优先级最高**,因为它最具体。识别 `CLAUDE.md` / `AGENTS.md` / `AI.md` / `.ai/AGENTS.md`。 `projectRoot` 是向上查找的边界:没有边界的话会一路找到文件系统根,把别的项目甚至用户主目录的规则也拉进来。已加载过的路径会跳过——同一份规则注入两遍不会让模型更遵守它,只会白占上下文。 ### 记忆整理(MemoryConsolidator) **不要让所有工具结果自动进记忆。** 那样记忆很快会变成一堆噪音:读过的每个文件、跑过的每条命令都在里面,真正重要的两三条反而被淹没。 ```text Events → Task Result → Reflection → Memory Candidate → Consolidation → Memory ``` ```php $consolidator = $agent->memoryConsolidator(); $consolidator->propose('project', '登录走 JWT,密钥在 config/jwt.php', ['confidence' => 0.9]); $consolidator->proposeFromReflection($reflectionResult); // 反思结论值得记 $consolidator->proposeFromResult($agentResult, 'task'); // 只取首段,不整段塞 $written = $consolidator->consolidate(); // 去重 + 滤低置信度 + 按置信度排序 + 截断 ``` 候选不会立刻写盘——`consolidate()` 之前它们只在内存里排队。这一步存在的意义就是「攒一批再筛」:单条判断谁重要很难,一批放一起比较就容易多了。 去重用检索器的相关性打分做近似判断,说的是同一件事就不重复写。敏感内容用筛选器挡掉: ```php $consolidator->setFilter(function (array $candidate) { return strpos($candidate['content'], '密码') === false; }); ``` ### 多角色团队(AgentTeam) 从「父 Agent 派生子 Agent」升级为「一组各司其职的角色协作」。区别在于成员是长期存在的:Developer 改完代码,Tester 拿到的是同一轮任务的上下文,Reviewer 能看到前两者的结论。 ```php use Ai\Agent\Team\AgentRole; $team = $agent->team([ AgentRole::developer(), AgentRole::tester(), AgentRole::reviewer(), ]); // 单独分派 $result = $team->assign('developer', '实现登录接口'); echo $result['status']; // 'completed' echo $result['text']; // 流水线:前一环的输出作为后一环的输入 $results = $team->pipeline('给 Auth 模块补测试', ['developer', 'tester', 'reviewer']); echo $team->toSummary(); // [developer] 给 Auth 模块补测试(completed,4 轮):已补充 3 个测试用例… // [tester] … ``` 内置五个角色:`manager` / `developer` / `tester` / `security` / `reviewer`,各自的系统提示词写明了职责边界——比如 Tester 的提示词明确「发现问题时给出复现步骤,不要自己去改实现代码」,否则多角色协作很容易退化成每个角色都在改代码。 自定义角色,并按角色收窄工具: ```php $team->addMember(new AgentRole('dba', [ 'description' => '数据库结构与查询优化', 'prompt' => '你是 DBA,只负责表结构与索引,不改业务代码。', 'tools' => ['read_file', 'bash'], // 只给这两个工具 'maxIter' => 8, ])); ``` 成员共享团队统一的工具集与权限配置,但**各自持有独立的 AgentRuntime 与上下文**——这正是多角色的意义:Tester 的上下文里不该塞满 Developer 的思考过程。 某个成员抛异常时记为 `status = failed` 继续往下走,不会中断整条流水线——Reviewer 知道 Tester 挂了,比什么都不知道有用。 ### Agent 间通信(AgentCommunication) 成员之间的消息在总线上投递与留存。纯文本传递会丢类型与来源,收到的一方分不清这是任务分派还是结果汇报,`type` 字段就是为此存在的。 ```php use Ai\Agent\Team\AgentMessage; $team->send(AgentMessage::bug('tester', 'developer', 'AuthTest::testLogin 失败:期望 true 实际 false', [ 'file' => 'tests/AuthTest.php', 'line' => 42, ])); $team->broadcast('需求已冻结,不要再改接口签名'); $bus = $team->communication(); $bus->unreadCount('developer'); // 未读条数 $bus->peek('developer'); // 看但不取走 $bus->inbox('developer'); // 取走(标记已读) $bus->history(AgentMessage::TYPE_BUG); // 全量历史,可按类型过滤 $bus->between('tester', 'developer'); // 两个角色之间的往来 ``` 消息类型:`task`(任务分派)/ `bug`(缺陷反馈)/ `review`(审查意见)/ `status`(状态同步,默认广播)/ `result`(执行结果)。 **分派任务时收件箱里的未读消息会自动拼在任务描述前面**——上一环留给他的话,不该等他自己去问。全量历史留在 `history()` 里供事后复盘:多角色协作出问题时,光看最终结果判断不出是哪一环传歪了。 ### 人工审批(ApprovalWorkflow) AI 改完代码不直接算数:先提交审核(附 diff),等人批准才继续,驳回则带着理由退回去改。企业环境里这是硬要求——没有人签字的自动改动进不了生产。 ```php $workflow = $agent->enableApproval('/var/data/approvals'); // AI 侧:改完提交 $request = $agent->submitForApproval($diff, [ 'summary' => '修复登录 401', 'files' => ['src/Auth.php'], ]); // 人工侧:另一个进程 / 后台页面 foreach ($workflow->getPendingRequests() as $req) { echo $req->toSummary(); // 摘要 + 涉及文件 + diff } $workflow->approve($request->getId(), '张三'); // 或:$workflow->reject($request->getId(), '缺少输入校验', '李四'); // AI 侧:拿结果 $status = $workflow->getStatus($request->getId()); // approved / rejected / pending_review / expired if ($status === \Ai\Agent\Approval\ApprovalRequest::STATUS_REJECTED) { $agent->ask($workflow->getRequest($request->getId())->toRejectionPrompt()); } ``` **审批天然跨进程**:提交的是 Agent,批准的是人,中间可能隔几小时。所以传了目录时请求会落盘,`getStatus()` 每次都从磁盘重读——只看内存副本会一直看到 `pending`,Agent 崩了重启也能接着等。不传目录则只放内存,适合同进程内的交互式确认。 其它能力: ```php $workflow->waitFor($id, 300); // 阻塞等待,超时返回当前状态而不是无限期挂着 $workflow->onSubmit(function ($req) { … }); // 提交时发邮件 / 飞书 / 建工单 $workflow->purgeDecided(); // 清掉已处理与已过期的 new ApprovalWorkflow('', ['ttl' => 3600]); // 请求 1 小时后过期 new ApprovalWorkflow('', ['autoApprove' => true]); // 本地调试用,自动过审 ``` 已处理或已过期的请求不能再批——审批结果只能出一次。一个三天前提的审批不该还能被批准,所以设了 `ttl` 的请求过期后状态直接变成 `expired`,不必等谁来清理。 ### 浏览器工具(BrowserTool) 让 Agent 操作真实浏览器:打开页面、点击、填表、截图、提取内容。跟 HTTP 抓取的区别是这里跑的是 Chrome——JS 渲染出来的内容、登录后的状态、前端路由,抓 HTML 拿不到的这里都拿得到。 ```php use Ai\Agent\Tools\BrowserTool; $agent->addTool(new BrowserTool(['headless' => true])); // 模型调用: // browser(action: "open", url: "https://example.com") // browser(action: "wait", selector: "#results") // browser(action: "type", selector: "#q", text: "php") // browser(action: "click", selector: "button[type=submit]") // browser(action: "extract", selector: ".result") // browser(action: "screenshot", path: "shot.png", full_page: true) // browser(action: "close") ``` 会话是复用的:一次 `open` 之后,后续 `click` / `type` 都在同一个页面上,登录态和页面状态都还在。 直接用 `BrowserSession`: ```php use Ai\Agent\Tools\BrowserSession; if (!BrowserSession::isAvailable()) { echo '本机没装 Chrome / Chromium'; } $session = new BrowserSession(['headless' => true, 'timeout' => 30]); $session->launch(); $session->navigate('https://example.com'); echo $session->title(); echo $session->text('h1'); print_r($session->extractAll('.item', 20)); $session->waitFor('#ready', 5000); $session->evaluate('document.querySelectorAll("a").length'); $session->screenshot('/tmp/shot.png', true); $session->close(); ``` 实现走 Chrome DevTools Protocol:`--remote-debugging-port` 起一个常驻 headless 实例,用 CDP 的 HTTP 端点拿页面目标,用 WebSocket 发命令。点击与输入通过页面内 JS 完成(输入后会派发 `input` / `change` 事件,否则 Vue / React 收不到值变化),不是模拟鼠标坐标——坐标点击遇到滚动和浮层就不准了。 **前置条件**:本机装了 Chrome / Chromium,且允许 `proc_open`。没装时工具返回明确的错误信息而不是抛异常——模型看到「没有浏览器」可以换个办法,看到崩溃则只能重试。CDP 走本地 `ws://`,不需要 openssl 扩展。 传入 `PathSafety` 后截图路径受工作区限制: ```php $agent->addTool(new BrowserTool([], new \Ai\Agent\Tools\PathSafety('/var/www/project'))); ``` ### 浏览器授权工具(BrowserGrantTool) `BrowserTool` 拉起的是**服务端自己**的浏览器;这个取的是**用户那台浏览器**的驱动权——扩展装在里面、登录态是他的、窗口就在他眼前。凭据不能凭空要,得让用户亲自点一次确认,所以走「匿名票据」流程: ```php use Ai\Agent\Tools\BrowserGrantTool; $agent->addTool(new BrowserGrantTool('https://example.com', ['app' => 'MCI AI Agent'])); // 模型调用: // browser_authorize(action: "start") → 申请票据,把授权链接经事件交出去 // browser_authorize(action: "status") → 用户确认后取回 device + 密钥 // browser_authorize(action: "status", wait_seconds: 60) → 在服务端等一会儿再问,别空转多次 // browser_authorize(action: "credential") → 看本地已存的凭据(不回密钥明文) // browser_authorize(action: "forget") → 清掉本地凭据与待确认票据 ``` **新标签页由宿主打开**:后端开不了用户的浏览器。`start` 会 emit 一个 `browser_authorize` 事件(带 `authorize_url` / `app` / `expires_in`),宿主的 SSE / WebSocket 前端据此 `window.open(authorize_url)`;用户确认后 `status` 再 emit `browser_credential`(只带 `device` / 密钥前缀,不带明文)。 授权天然跨轮次——用户可能在下一轮才去点确认。所以票据与凭据落在**按身份隔离**的目录里(有 `userId` 走 `users//`,否则退到 `projects//`),目录 0700、文件 0600;密钥明文只在取回那一次交给模型,本地留档是给后续步骤用的。身份不明(`storageDir` 为空,既没 userId 也没 sessionId)时工具**拒绝写盘**并说明原因——两个身份共用一份能驱动浏览器的密钥,比没有凭据更糟: ```php $agent->setUserId('admin:7')->setSessionId('sess-1'); // 至少给一个,才有地方安全地放凭据 ``` `wait_seconds` 有上限(默认 120,最多 300),且会响应取消——用户点停止时不会继续在服务端干等。 ### 浏览器操作工具(MciBrowserTool) 上面那个只把凭据**取回来**,之后怎么驱动浏览器还得自己接;`MciBrowserTool` 把「授权」与「操作」收进同一个工具名,模型只记一件事: ```php use Ai\Agent\Tools\MciBrowserTool; $agent->addTool(new MciBrowserTool('https://example.com', ['app' => 'MCI AI Agent'])); // 授权那一半(没凭据时先做;与 browser_authorize 同名同义,原样委托过去): // mci_browser(action: "authorize") → 弹确认页,取票据 // mci_browser(action: "grant", wait_seconds: 60) → 用户确认后取回 device + 密钥 // // 操作那一半(有凭据即可,直通站点 /Browser/Api/*): // mci_browser(action: "devices") → 哪几台浏览器在线 // mci_browser(action: "open", url: "https://example.com") → 后台开一个标签页 // mci_browser(action: "text", selector: "h1") → 读正文 // mci_browser(action: "eval", expression: "document.title") → 执行 JS 取回结果 // mci_browser(action: "screenshot") → 截图直接进上下文 ``` 动作共 25 个:查看类 `devices` / `tabs` / `url` / `text` / `html` / `form` / `metrics` / `console` / `network` / `cookies` / `quota`,操作类 `open` / `click` / `type` / `press` / `scroll` / `select` / `wait` / `reload` / `back` / `forward` / `close` / `activate`,脚本类 `eval` / `screenshot`。 几处刻意的取舍: - **授权规则只写一处**:`authorize` / `grant` / `credential` / `forget` 四个动作直接委托给 `BrowserGrantTool`,票据、轮询、落盘、事件保持完全一致(宿主前端不用改)。 - **`device` 默认取凭据里授权的那台**:站点上在线的是哪台不由模型猜 —— 他授权时选的是哪台就一直用哪台。多台在线时猜错就是把命令打到别人的浏览器上;确实要换台时显式传 `device`。 - **`open` 默认 `active=0`**:AI 干活多在后台,抢走焦点等于打断用户手头的事。 - **自己开的标签要自己关**:`open` 的结果里直接附上带 tab id 的关闭命令,工具描述也把「收尾用 `close`」写成硬要求;不传 `tab` 时关的是你上次操作的那个标签,用户的其它标签不受影响。少了这条,AI 每干一次活就在用户浏览器里留下一排标签。 - **截图走 `inline=1` 取字节**交给媒体门面,图直接进上下文,模型是真看得见页面;站点上那份落盘路径也一并回报。调用方没挂媒体门面时,结果里会**明说这次看不到图**,而不是让模型以为自己看过了。 - **参数按动作走白名单**:模型多给的键(通用目标 `device` 除外)不会原样转给站点。少了这层,工具就成了「把模型输入拼进请求」的通道。 - **限流、配额、来源白名单、审计都还在站点那侧**(`/Browser/Api/*`),漏过这一层不会绕过任何一道。 ### 代码理解(CodeAnalyzer) Agent 改代码前得先看懂代码。`Ai\Code` 扫描项目建立类索引与两张关系图——谁调用了谁、谁依赖了谁——之后回答「改这个方法会影响谁」不用再 grep 整个项目。 ```php use Ai\Code\CodeAnalyzer; $analyzer = new CodeAnalyzer(); $analyzer->scan('/var/www/project/src'); print_r($analyzer->stats()); // ['files' => 189, 'classes' => 189, 'methods' => 2107, 'callEdges' => 3604, 'dependencyEdges' => 422] $analyzer->findCallers('login'); // 谁调用了 login $analyzer->findDependencies('App\Auth'); // Auth 依赖谁 $analyzer->findDependents('App\Auth', true); // 谁(间接)依赖 Auth —— 改动影响面 $analyzer->findRelatedFiles('src/Auth.php'); // 该一起看的文件 $analyzer->findSymbol('login'); // 符号定义在哪个文件第几行 ``` `explain()` 生成一段可直接注入提示词的类说明,比把整个类的源码塞进上下文省得多,而且带上了单看源码看不到的「谁依赖我」: ```php echo $analyzer->explain('App\Service\Auth'); // class App\Service\Auth extends App\Service\BaseAuth implements App\Contract\AuthInterface // file: src/Service/Auth.php:23 // public function login($name, $password = ...): bool # line 41 // protected function verify($user, $password): bool # line 47 // 依赖: App\Model\User, App\Support\Token // 被依赖: App\Http\LoginController // 继承链: App\Service\BaseAuth // 子类: App\Service\AdminAuth ``` **单文件分析**——不建索引也能用: ```php use Ai\Code\FileAnalyzer; $analysis = (new FileAnalyzer())->analyze('src/Auth.php'); echo $analysis->getNamespace(); // 'App\Service' print_r($analysis->getImports()); // ['User' => 'App\Model\User', ...] $class = $analysis->getMainClass(); echo $class->getParent(); // 父类完整名(已按 import 解析) print_r($class->getMethods()); // 方法名 => FunctionAnalysis echo $class->getMethod('login')->getSignature(); // 'public function login($name, $password = ...): bool' ``` **两张图**——`CallGraph` 与 `DependencyGraph` 也能单独用: ```php $analyzer->callGraph()->impactOf('App\Auth::login'); // 直接间接会调到 login 的函数 $analyzer->callGraph()->unreferenced(); // 没有调用方的函数(候选死代码) $analyzer->dependencyGraph()->detectCycles(); // 循环依赖 $analyzer->dependencyGraph()->mostDepended(10); // 被依赖最多的类 —— 改它风险最高 $analyzer->dependencyGraph()->layers(); // 按依赖深度分层 $analyzer->classAnalyzer()->allMethods('App\Auth', $analyzer->index()); // 含继承与 trait 的方法表 ``` **精度说明**:解析基于 `token_get_all()`,不依赖任何第三方 parser。能认命名空间、import(含成组导入与别名)、继承实现、方法签名(参数类型、可空、可变参数、返回类型)、属性可见性、类常量、函数体内的调用。 认不了的:变量的实际类型。`$obj->save()` 拿不到 `$obj` 是什么类,图里记的是 `->save`,查 `save` 的调用方时所有类的同名方法会一起命中。**结果是「可能的调用方」,用来缩小排查范围可以,当作重构的唯一依据不行。** 动态调用(`$class::$method()`)与 `eval` 里的代码同理,静态扫描看不见。 ### 代码索引工具(CodeIndexTool) 把 `Ai\Code\CodeAnalyzer` 的能力交给 Agent:**一次扫描,反复查询**。explorer 调查一个类时不必每次都 grep 全项目,也不必把整个类的源码读进上下文。 ```php $agent->addTool(new \Ai\Agent\Tools\CodeIndexTool('/var/www/project/src')); // 模型调用: // code_index(action: "explain", target: "App\Auth") 类结构 + 继承链 + 谁依赖它 // code_index(action: "callers", target: "login") 谁调用了它 // code_index(action: "dependents", target: "App\Auth") 改动影响面 // code_index(action: "related", target: "src/Auth.php") 该一起看的文件 // code_index(action: "symbol", target: "login") 定义在哪一行 // code_index(action: "stats") 索引规模 // code_index(action: "refresh") 改过文件后重建索引 ``` `codeAgent()` 会自动装上它,explorer / planner / reviewer / debugger 四个内置角色也都带着它——它们的活就是「先看懂再说」。 索引是惰性建立的(首次调用时扫描),之后常驻内存。**改过文件要 `refresh`**,否则查到的还是旧结构。 工具返回里会带上精度提醒:`$obj->save()` 拿不到接收者的真实类型,查 `callers` 时同名方法会一起命中。这是静态扫描的固有限制,结果是「可能的调用方」。 ### 项目索引(RepositoryIndexer) 首次进入一个项目时扫一遍结构,认出框架、入口、控制器 / 模型 / 服务 / 配置在哪,存成 `project.index.json`。之后 Agent 直接读索引,不用每次都把项目结构重新摸一遍。 ```php use Ai\Agent\Code\RepositoryIndexer; $indexer = new RepositoryIndexer(); $index = $indexer->ensureIndex('/var/www/project'); // 有且没过期就复用,否则重建 echo $index->getFramework(); // 'Laravel' echo $index->getEntry(); // 'public/index.php' print_r($index->getFiles('controllers')); print_r($index->getNamespaces()); // ['App\' => 'app/'] echo $index->toSummary(); // 注入提示词的 块 ``` 框架识别先看 composer 依赖,再看目录特征,覆盖 Laravel / Symfony / CodeIgniter / ThinkPHP / Yii / CakePHP / Laminas / Slim / WordPress。**认不出来时返回空串而不是猜**——猜错会让模型按错误的约定去找文件,比不知道更糟。 索引过期的判据是 ttl(默认 1 天)或 composer.json 变新,不逐个文件比 mtime——大项目上那样太慢。文件分类是按路径与文件名的启发式判断(`app/Http/Controllers/Auth.php` → controllers),不读文件内容。 ### 用户交互(AskUser) 当任务描述不明确、存在多个合理执行方案或涉及关键选择时,Agent 可以向用户提问,而不是自行猜测。这与 Permission 有本质区别: - **Permission**:回答「能不能执行?」(权限检查) - **AskUser**:回答「应该怎么做?」(Agent 主动向用户提问) ```php use Ai\Agent\Interaction\UserInteractionManager; $uim = new UserInteractionManager(); $agent ->setUserInteractionManager($uim) ->setTools([...]); $result = $agent->getRuntime()->run($messages); // 暂停后等待用户回答 if ($result->getStopReason() === 'waiting_user') { $questionId = $result->getExtra()['question_id'] ?? ''; $answer = 'main'; // 用户的选择 // 回答后恢复执行 $result = $agent->answerUser($questionId, $answer, $messages); echo $result->getText(); } ``` Agent 会在需要时调用 `ask_user` 工具,暂停并等待用户回答。`answerUser()` 将答案回填给模型继续执行。 ### 钩子系统(Hooks) 在 Agent 执行链的关键节点注入自定义逻辑,无需修改核心工具。钩子覆盖完整的生命周期:工具执行前后、模型调用前后、权限请求、任务、子 Agent、上下文压缩、会话启停。 ```php $agent // 工具执行前调用:返回 ToolResult 可短路执行(跳过实际工具) ->onBeforeTool(function ($name, $input, $ctx) { if ($name === 'bash') { // 记录所有 bash 命令到审计日志 return null; // 返回 null 继续执行 } }) // 工具执行后调用:可修改/包装结果 ->onAfterTool(function ($name, $result) { return $result; }) // 模型调用前调用:可修改请求参数 ->onBeforeModel(function ($messages, $tools) { // 注入额外的系统消息或过滤敏感数据 return ['messages' => $messages, 'tools' => $tools]; }) // 模型调用后调用:可记录响应或注入额外数据 ->onAfterModel(function ($resp) { return $resp; }); ``` 完整钩子及签名: | 钩子 | 注册方法 | 签名 | 触发时机 | |------|---------|------|---------| | `before_tool` | `onBeforeTool` | `(string $name, array $input, ToolContext $ctx): ?ToolResult` | 工具执行前;返回 `ToolResult` 则短路,不执行实际工具 | | `after_tool` | `onAfterTool` | `(string $name, ToolResult $result): ToolResult` | 工具执行后;可修改/包装结果 | | `tool_error` | `onToolError` | `(string $name, ToolResult $result): void` | 工具执行出错后(或权限拒绝时) | | `after_tool_batch` | `onAfterToolBatch` | `(array $results): array` | 一批工具全部执行完成后、下一次模型调用前;可统一审计/刷新状态 | | `before_model` | `onBeforeModel` | `(array $messages, array $tools): array` | 模型调用前;返回 `['messages'=>..., 'tools'=>...]` 修改请求参数 | | `after_model` | `onAfterModel` | `($response): $response` | 模型调用后;可修改/记录响应 | | `permission_request` | `onPermissionRequest` | `(string $toolName, array $input, string $requestId): HookResult` | 权限请求创建时;返回 `HookResult` 可表达处理意见 | | `task_start` | `onTaskStart` | `(string $taskId, string $goal): void` | 任务开始执行 | | `task_complete` | `onTaskComplete` | `(string $taskId, string $result): void` | 任务正常完成 | | `task_failed` | `onTaskFailed` | `(string $taskId, string $error): void` | 任务执行失败 | | `subagent_start` | `onSubagentStart` | `(string $agentName, string $task): void` | 子 Agent 启动 | | `subagent_stop` | `onSubagentStop` | `(string $agentName, string $result): void` | 子 Agent 结束 | | `before_compact` | `onBeforeCompact` | `(int $tokenCount, int $messageCount): void` | 上下文压缩前 | | `after_compact` | `onAfterCompact` | `(int $messageCount): void` | 上下文压缩后 | | `agent_start` | `onAgentStart` | `(): void` | Agent 循环开始 | | `agent_stop` | `onAgentStop` | `(string $stopReason): void` | Agent 循环结束(携带停止原因) | `onTaskFailed` / `onAgentStart` / `onAgentStop` 三个钩子不在 `Agent` 的快捷方法里,需要直接注入钩子容器: ```php use Ai\Agent\Hooks\AgentHooks; $hooks = new AgentHooks(); $hooks->onTaskFailed(function ($taskId, $error) { log_message('error', "任务 {$taskId} 失败:{$error}"); }); $hooks->onAgentStart(function () { echo 'Agent 开始执行'; }); $hooks->onAgentStop(function ($stopReason) { echo "Agent 停止:{$stopReason}"; }); $agent->getRuntime()->setHooks($hooks); ``` #### HookResult:钩子统一返回值 `Ai\Agent\Hooks\HookResult` 是钩子的统一返回类型,支持五种动作: | 动作 | 工厂方法 | 说明 | |------|---------|------| | `CONTINUE` | `HookResult::go()` | 继续执行(默认) | | `ALLOW` | `HookResult::allow()` | 放行 | | `DENY` | `HookResult::deny($reason)` | 拒绝,附理由 | | `MODIFY` | `HookResult::modify($data)` | 修改输入后继续 | | `STOP` | `HookResult::stop($reason)` | 停止 Agent | ```php use Ai\Agent\Hooks\HookResult; $hooks->onPermissionRequest(function ($toolName, $input, $requestId) { if ($toolName === 'bash' && strpos($input['command'], 'DROP TABLE') !== false) { return HookResult::deny('禁止执行 DROP TABLE'); // 拒绝并说明理由 } return HookResult::go(); // 继续默认流程 }); ``` `HookResult` 提供 `getAction()` / `getReason()` / `getData()` 与 `isContinue()` / `isAllow()` / `isDeny()` / `isModify()` / `isStop()` 判断方法。 #### 执行顺序 钩子按固定顺序接入执行链: ```text Model ↓ Tool Call ↓ before_tool(可短路) ↓ Permission ↓ Tool 执行 ↓ tool_error(出错时)/ after_tool ↓ after_tool_batch(整批完成后) ↓ Tool Result 回填 ↓ Model ``` 注意:**钩子的放行不能绕过硬性权限拒绝**。权限优先级保持 `Deny 规则 → Permission Deny → Allow → Ask`,`before_tool` 只能短路「将要执行的工具」,不能解除权限系统已做出的拒绝。 ### 任务系统(Task) Agent 任务系统将「整个用户目标」与「每一轮模型交互」分离。一个 Task 包含多个 Turn,支持完整的生命周期管理。 核心组件: - `AgentTask` — 任务值对象,包含 id、goal、status、parentTaskId、sessionId - `TaskState` — 任务状态记录,包含 completed、pending、blocked、importantFacts、modifiedFiles、errors、subtasks - `TaskManager` — 任务管理器,管理任务生命周期(queued → running → waiting_permission/waiting_user → paused → completed/failed/cancelled) - `TaskStatus` — 状态枚举常量 ```php use Ai\Agent\Task\TaskManager; $tm = new TaskManager(); // 创建任务 $task = $tm->create('修复登录问题', 'sess_abc'); // 通过 AgentRuntime 执行任务 $result = $tm->start($task->getId(), $runtime, $messages); // 生命周期控制 $tm->pause($task->getId()); $tm->resume($task->getId()); $tm->cancel($task->getId()); ``` #### TaskState 进度记录 TaskState 记录详细执行进度,适合注入 Context Compaction 后的系统提示词,让 Agent 在上下文压缩后仍知道任务状态: ```php use Ai\Agent\Task\TaskState; $state = new TaskState(['goal' => '修复登录问题']); $state->addCompleted('找到 Auth.php'); $state->addPending('运行 PHPUnit'); $state->addModifiedFile('Auth.php'); $state->addImportantFact('session 过期时间设置错误'); // 生成进度摘要 echo $state->toSummary(); // # 任务状态 // 目标:修复登录问题 // // ## 已完成 // - 找到 Auth.php // // ## 待处理 // - 运行 PHPUnit // // ## 修改的文件 // - Auth.php // ... ``` #### 通过 Agent 注入 TaskManager ```php $agent = (new Agent($ai)) ->setSystem('助手') ->setTaskManager($tm) // 注入任务管理器 ->setTaskId($task->getId()); // 关联当前任务 $agent->run($messages); // 任务完成后自动标记为 completed 或 failed // 同时发出 task_start / task_complete / task_failed 事件 ``` 任务生命周期事件可通过 `onEvent()` 接收: | 事件 | 说明 | |------|------| | `task_start` | 任务开始执行 | | `task_complete` | 任务正常完成 | | `task_failed` | 任务执行失败(含异常/错误) | ### 运行时架构 v2.0 内部结构: ``` Agent(对外 API) ├── setTools() / setSystem() / onEvent() / setMaxIter() / run() ├── setPermissionMode() / setSessionId() / setMaxBudget() ├── setFallbackModels() / setToolTimeout() ├── setTaskManager() / setTaskId() ← 任务系统 ├── setUserInteractionManager() ← 用户交互 ├── setVerification() ← 验证管理 ├── setWorkspaceDir() ← 工作区管理 ├── loadSkills() / setSkillManager() ← 技能系统 ├── loadInstructions() / setInstructionManager() ← 项目指令 ├── setMcpServers() / setMcpManager() ← MCP Runtime ├── setMemoryDir() / setMemoryManager() ← 作用域记忆 ├── setCheckpointDir() / recoverFromCrash() ← 检查点 / 崩溃恢复 ├── approve() / deny() ← 用户授权 ├── answerUser() ← 回答用户提问 ├── onBeforeTool() / onAfterTool() ← 工具钩子 ├── onToolError() / onAfterToolBatch() ← 工具错误 & 批量后置钩子 ├── onBeforeModel() / onAfterModel() ← 模型钩子 ├── onPermissionRequest() ← 权限钩子 ├── onTaskStart() / onTaskComplete() ← 任务钩子 ├── onSubagentStart() / onSubagentStop() ← 子 Agent 钩子 ├── onBeforeCompact() / onAfterCompact() ← 上下文压缩钩子 └── getRuntime() ─────────────────────────────────→ AgentRuntime(执行引擎) ├── ToolRegistry(工具注册表) ← AgentToolInterface 注册 ├── ToolExecutor(工具执行器) ← 重试 / 超时 / 输出截断 ├── LoopController(自循环控制器) ← 驱动主循环(含降级模型) ├── LoopGuard(防死循环守卫) ← 重复调用检测 + 结果级进展检测 ├── ParallelToolExecutor(并行执行器) ← 并行安全工具 ├── PermissionManager(权限管理器) ← 6 种模式 + 规则匹配 ├── ContextManager(上下文管理器) ← Turn-aware 自动压缩 ├── SessionManager(会话管理器) ← 持久化 / 暂停 / 恢复 ├── BudgetManager(预算管理器) ← token / 成本控制 ├── VerificationManager(验证管理器) ← 工具执行后自动验证 ├── WorkspaceManager(工作区管理器) ← Git 状态跟踪 ├── SkillManager(技能管理器) ← 技能目录 + use_skill 工具 ├── InstructionManager(项目指令管理器) ← CLAUDE.md / AGENTS.md ├── McpManager(MCP 管理器) ← stdio JSON-RPC 工具 ├── MemoryManager(记忆管理器) ← 分作用域长期记忆 ├── CheckpointManager(检查点管理器) ← 每轮 checkpoint 自动保存 ├── SubAgentManager(子 Agent 管理器) ← spawn_agent 工具 ├── TaskManager(任务管理器) ← 任务生命周期(AgentTask / TaskState) ├── UserInteractionManager(用户交互管理器) ← ask_user 工具 └── AgentHooks(钩子系统) ← 完整生命周期钩子:before/after_tool、tool_error、 after_tool_batch、before/after_model、 permission_request、task_start/complete/failed、 subagent_start/stop、before/after_compact、 agent_start/stop ``` 通过 `$agent->getRuntime()` 可访问全部内部组件,实现高级定制。 ### 停止原因(StopReason) Agent 停止时可通过 `$result->getStopReason()` 获取原因: | 常量 | 值 | 说明 | |------|------|------| | `END_TURN` | `end_turn` | 模型给出最终回答,正常结束 | | `MAX_ITER` | `max_iter` | 已达最大迭代次数上限 | | `NO_PROGRESS` | `no_progress` | 连续重复调用同一工具,无进展 | | `TOOL_ERROR` | `tool_error` | 工具执行出错 | | `USER_CANCEL` | `user_cancel` | 用户取消 | | `BUDGET_EXCEEDED` | `budget_exceeded` | 预算超限 | | `TIMEOUT` | `timeout` | 超时 | | `PERMISSION_DENIED` | `permission_denied` | 权限被拒绝或等待用户授权 | | `WAITING_USER` | `waiting_user` | 等待用户回答问题(ask_user) | | `MODEL_ERROR` | `model_error` | 模型返回错误 | --- ## Agent Tool 标准与 Tool Registry 把传统 PHP 应用**已有的、已经受权限控制的业务能力**标准化成 Agent 可以发现和调用的 Tool——不重写业务逻辑,不新建一套权限系统。 ```text Controller / Service + @agent-tool 标注 ↓ php-ai index SQLite Tool Registry(FTS5 全文索引) ↓ search_app_tools / get_app_tool Agent 按需加载 Tool Schema ↓ call_app_tool 应用现有 Controller 入口权限校验 ← 唯一的安全边界 ↓ Controller / Service → 业务数据库 ``` 一句话原则:**让 AI 能够发现应用有什么能力,但永远不能自行决定这些能力是否有权执行。** ### 为什么需要它 Agent 接业务系统时通常有两条路,都不好走: - 把几千个业务能力全塞进 prompt——token 烧光,模型还挑不准; - 给 Agent 一个「执行任意 SQL」的工具——绕过了权限、缓存清理、审核状态、日志、Webhook 等一整套业务逻辑。 这套方案走第三条:能力目录进数据库,模型按需搜索、按需加载,执行时回到应用原有的 Controller 入口。 ### 三行接入 ```php use Ai\Agent\Indexer\ToolIndexer; use Ai\Agent\Registry\SqliteToolRegistry; use Ai\Agent\Registry\CallableControllerGateway; use Ai\Agent\Discovery\RegistryToolBridge; use Ai\Agent\Registry\ToolSearchContext; // 1. 发布阶段:扫描应用,写入 Registry(也可以用 CLI: php-ai index) $registry = new SqliteToolRegistry(APP_PATH . '/.ai/registry.sqlite'); (new ToolIndexer($registry))->scan([APP_PATH . '/Controller', APP_PATH . '/Service']); // 2. 运行时:告诉 php-ai 怎么走你现有的 Controller 入口 $gateway = new CallableControllerGateway(function ($path, array $args, array $ctx) { // ⚠️ 这里必须跑你现有的权限校验,它是最终安全边界 if (!$auth->check($ctx['user_id'], $path)) { throw new \RuntimeException('无权访问 ' . $path); } return $router->dispatch($path, $args); }); // 3. 接进 Agent $bridge = new RegistryToolBridge($registry, $gateway); $bridge->setContext(new ToolSearchContext([ 'user_id' => $user->id, 'permissions' => $auth->permissionsOf($user), // 可选:Discovery 阶段的候选过滤 ])); $agent->tools($bridge->tools()); ``` 完整可运行的演示见 `examples_tool_registry.php`(自带样本应用与临时数据库,跑完自清理,不需要 API Key)。 ### PHPDoc 标注标准 在**已有的** Controller / Service 方法上加几行注释即可,业务代码一行不用改: ```php /** * 修改文章 * * @agent-tool article.update * @agent-description 修改指定文章的标题、摘要和正文 * @agent-controller article/update * @agent-risk medium * @agent-confirm false * @agent-keywords 文章,编辑,标题 * * @param int $id 文章 ID * @param string|null $title 文章标题 * @param string|null $content 文章正文 * @return array 修改后的文章 */ public function update($id, $title = null, $content = null) { // 原有业务逻辑,不用改 } ``` | 标签 | 必需 | 说明 | |------|------|------| | `@agent-tool` | ✅ | Tool 名,推荐 `资源.动作`(`article.update`)而不是类名方法名——类改名不会影响 Agent | | `@agent-controller` | ✅ | 映射到的现有 Controller 入口路径,**权限与执行都走它** | | `@agent-description` | 否 | 给模型看的能力描述;不写则取 docblock 首行摘要 | | `@agent-risk` | 否 | `low` / `medium` / `high` / `critical`,默认 `low` | | `@agent-confirm` | 否 | `true` / `false`,覆盖风险等级推导出的确认要求 | | `@agent-permission` | 否 | 可多次出现;仅用于 Discovery 候选过滤,**不是**授权依据 | | `@agent-keywords` | 否 | 逗号分隔的补充搜索词(中文同义词很有用) | | `@agent-enabled` | 否 | `false` 可临时下线某个能力 | | `@agent-version` | 否 | Tool 版本 | | `@param` / `@return` | 否 | 补充 Reflection 拿不到的**描述**与更精确的类型 | **没有 `@agent-tool` 的 public 方法不会进 Registry。** 内部 API、辅助方法、管理方法不会被误暴露——这一点是硬规则,不是配置项。 ### PHP 8 Attribute(可选) PHP 8+ 项目可以改用 Attribute,拼错标签名会被 IDE 和静态分析当场发现: ```php use Ai\Agent\Attribute\AgentTool; #[AgentTool(name: 'article.update', description: '修改文章', controller: 'article/update', risk: 'medium')] public function update(int $id, ?string $title = null): array { } ``` 字段优先级:**Attribute > PHPDoc > Reflection 推断**。Attribute 没给的字段仍从 PHPDoc 取,两者可以混用。PHP 7 环境下 Attribute 被当成注释忽略,自动只走 PHPDoc,不会报错。 ### 参数 Schema 自动生成 Reflection 负责类型、必填、默认值,PHPDoc 负责描述,两边合并成 JSON Schema: ```json { "type": "object", "properties": { "id": { "type": "integer", "description": "文章 ID" }, "title": { "type": ["string", "null"], "description": "文章标题" }, "status": { "type": "string", "description": "状态", "enum": ["draft", "published"] }, "page": { "type": "integer", "description": "页码", "default": 1 } }, "required": ["id"] } ``` 支持 `int` / `float` / `string` / `bool` / `array` / `object` / 可空 `?T` / PHP 8 联合类型 / PHP 8.1 backed enum,以及 docblock 里的 `int[]`、`array`、字面量联合 `'draft'|'published'`(自动变成 `enum`)。 ### 索引与 CLI ```bash php-ai index # 按配置扫描并写库 php-ai index --path=app/Controller # 指定目录(可重复) php-ai index --clear # 先清空再全量扫描 php-ai index --check # 只检查是否需要重建,过期时退出码 1 php-ai index --force # 忽略文件 hash,全部重扫 php-ai tools # 列出全部 Tool php-ai tools:search "文章 修改" # 搜索 Tool php-ai tools:show article.update # 打印完整定义与 Schema php-ai tools:remove article.update # 从 Registry 删除 ``` 公共选项:`--db=PATH`(默认 `.ai/registry.sqlite`)、`--config=PATH`、`--bootstrap=PATH`(先加载应用自己的 autoloader)、`--json`、`--quiet`。 路径也可以写进配置文件,之后直接 `php-ai index`: ```php // .ai/config.php return ['agent' => ['index' => [ 'paths' => [APP_PATH . '/Controller', APP_PATH . '/Service'], 'excludes' => ['vendor', 'tests'], 'db' => APP_PATH . '/.ai/registry.sqlite', ]]]; ``` 推荐把 `php-ai index` 放进发布流程,避免第一次 Agent 请求时才做全量扫描: ```text Git Push → CI/CD → composer install → php-ai index → 应用启动 ``` CI 里可以用 `php-ai index --check` 校验索引是否跟代码同步(过期返回退出码 1)。 ### 增量扫描 每个源文件的内容 hash 存在 Registry 里,hash 没变就跳过,一万个文件里改了 12 个就只重新解析这 12 个。文件被删除、或标注被拿掉时,其名下的 Tool 会被自动清除。 扫描器还做了两层保护: 1. 文件内容里既没有 `@agent-tool` 也没有 `AgentTool` 时,**连 include 都不做**——业务代码里的顶层副作用(连数据库、注册路由)不会因为一次索引被跑起来; 2. 类名靠 `token_get_all` 词法提取,先给应用自己的 autoloader 机会,实在加载不到才 `require_once`。 > ⚠️ 一个 PHP 进程里同一个类只会被读到一次。同一次执行里改了源码再 `scan()`,Reflection 拿到的仍是先前载入的定义。正常用法(`php-ai index` 每次都是新进程)不受影响。 ### 按需发现:三个工具,而不是几千个 `RegistryToolBridge` 产出三个运行时工具,模型初始只看到它们: | 工具 | 作用 | |------|------| | `search_app_tools` | 用自然语言搜业务能力,返回候选摘要(名称 / 简介 / 风险) | | `get_app_tool` | 拉某个能力的完整 JSON Schema | | `call_app_tool` | 调用它(经 Controller 入口 + 应用现有权限校验) | 一次典型交互: ```text 用户:把昨天销量最高的商品价格降低 5% search_app_tools("商品 销量 价格") → product.sales.stats / product.update_price get_app_tool("product.sales.stats") → 执行 get_app_tool("product.update_price") → 执行 ``` 工具名刻意避开了 `ToolDiscovery` 的 `search_tools`:那个搜的是 Agent 自己的运行时工具(read_file / bash…),这里搜的是应用的业务能力,两者不是一回事。 ### 中文全文搜索 SQLite 的 `unicode61` 分词器把连续汉字当成一个 token,「修改文章的标题」整段是一个词,搜「文章」一无所获。因此写进 FTS5 索引的是归一化后的 token 串——汉字切成**单字 + 相邻二元组**,英文按分隔符切开并小写: ```text article.update 修改文章 → article update 修 改 文 章 修改 改文 文章 ``` 查询串走同一套切法,两边才对得上。单字保召回,二元组保精度。FTS5 不可用(编译期没开)时自动降级到 `LIKE` 扫描 + 纯 PHP 打分,功能不中断。 ### Registry 实现选择 `ToolRegistryInterface` 不绑定 SQLite,SQLite 只是默认实现: | 场景 | 建议 | |------|------| | Demo / 单元测试 | `MemoryToolRegistry`(零依赖,不需要任何扩展) | | 普通 PHP 网站 / 中型单服务器 | `SqliteToolRegistry`(默认) | | 多 PHP 实例 / SaaS | 自行实现 MySQL / PostgreSQL 版,Agent 上层逻辑不用改 | Registry 与业务数据库**默认独立**:它属于应用元数据,可以随时删掉重建,删了不影响任何业务数据,不同部署环境也可以有不同的 Registry。 表结构:`agent_tools` / `agent_tool_parameters` / `agent_tool_permissions` / `agent_index_files` / `agent_tools_fts`。 ### 风险等级与人工确认 **风险不是权限**,两者独立。`article.delete` 即使权限校验通过,也可以因为 `risk = high` 而要求用户先确认一下。 | 等级 | 默认行为 | |------|---------| | `low` | 直接执行 | | `medium` | 直接执行(权限由 Controller 把关) | | `high` | 需要确认 | | `critical` | 需要确认,且**不能**被阈值绕过 | ```php use Ai\Agent\Registry\RiskPolicy; use Ai\Agent\Tool\ToolDefinition; $policy = new RiskPolicy(); $policy->setThreshold(ToolDefinition::RISK_MEDIUM); // 中风险起就要确认 $policy->setOverride('article.delete', false); // 单个 Tool 免确认 $bridge = new RegistryToolBridge($registry, $gateway, ['risk_policy' => $policy]); ``` 未确认时 `call_app_tool` 返回失败结果,`metadata` 里带 `requires_confirmation`、`forced`、`risk` 与已校验的参数;把提示转达用户,得到同意后带 `confirmed => true` 重试即可。 `critical` 的强制确认是最后一道闸:`setThreshold` 和 `setOverride` 都绕不过它,只有显式调用 `allowCriticalWithoutConfirm(true)` 才能关掉。 ### 参数校验:模型给的参数不可信 执行前 `ArgumentValidator` 会做三件事: 1. **未声明的参数一律丢弃**,不透传给 Controller。让模型能往 Controller 塞任意字段,等于把入参白名单交给模型决定; 2. **必填缺失直接失败**,不用默认值蒙混过去; 3. **只做无歧义的类型收敛**——`"123"` → `123`、`"true"` → `true` 这类 JSON 传输损耗要修,但 `"abc"` → `0` 这种会把「模型填错了」变成「Controller 收到 0」的转换一律拒绝。 枚举取值不在允许范围时同样拒绝执行。 ### 三条安全红线 1. **Discovery 的权限过滤只是优化,不是安全边界。** `ToolSearchContext` 的 `permissions` 与网关的 `can()` 都跑在「模型还没决定用哪个 Tool」的时候,结果可能被缓存、可能过期,也可能因为对象级规则(「只能改自己写的文章」)而不完整。真正的授权只有一个地方:`ControllerGatewayInterface::dispatch()` 里应用自己的那套校验。 **`dispatch()` 里不做权限校验,是这套方案唯一的致命误用。** 2. **没有 `@agent-controller` 的 Tool 拒绝执行。** 没有 Controller 入口就没有权限边界。这种 Tool 仍会被索引(能被搜到,索引时会给出警告),但执行阶段直接拒绝——绝不退回「拿 class/method 反射直调」那条路。 3. **Agent 不碰业务数据库。** `registry.sqlite` 只存 Tool 元数据。业务数据永远走 Controller / Service,因为改一篇文章通常不只是一条 UPDATE:还有权限检查、缓存清理、搜索索引更新、修改日志、Webhook、审核状态、CDN 清理。这些都该由原有 Service 负责。 ### 审计 每次执行(成功 / 失败 / 被拒 / 待确认)都会回调一次: ```php $bridge->executor()->onExecuted(function (array $record) use ($logger) { $logger->info('agent_tool', $record); // tool / controller / risk / success / error / metadata / user_id / time }); ``` ### API 速查 | 类 | 作用 | |---|---| | `Ai\Agent\Tool\ToolDefinition` | Tool 元数据值对象,`schema()` 产出 JSON Schema | | `Ai\Agent\Tool\ToolParameter` | 单个参数 | | `Ai\Agent\Attribute\AgentTool` | PHP 8 Attribute | | `Ai\Agent\Indexer\ToolIndexer` | 扫描编排:`scan()` / `check()` / `scanClass()` / `clear()` | | `Ai\Agent\Indexer\PhpDocParser` | `@agent-*` 与 `@param` 解析 | | `Ai\Agent\Indexer\ReflectionSchemaBuilder` | Reflection → 参数 Schema | | `Ai\Agent\Registry\ToolRegistryInterface` | `search()` / `get()` / `register()` / `remove()` | | `Ai\Agent\Registry\SqliteToolRegistry` | 默认实现,SQLite + FTS5 | | `Ai\Agent\Registry\MemoryToolRegistry` | 内存实现,零依赖 | | `Ai\Agent\Registry\ToolSearchContext` | 搜索上下文与候选过滤 | | `Ai\Agent\Registry\ControllerGatewayInterface` | **应用唯一需要实现的接口** | | `Ai\Agent\Registry\CallableControllerGateway` | 闭包实现,小应用与测试直接可用 | | `Ai\Agent\Registry\ControllerToolExecutor` | 执行链:校验 → 风险 → 网关 | | `Ai\Agent\Registry\RiskPolicy` | 风险 → 是否需要确认 | | `Ai\Agent\Discovery\ToolSearcher` | 搜索 + 权限过滤 + 摘要 | | `Ai\Agent\Discovery\RegistryToolBridge` | 产出三个 Agent 运行时工具 | `SqliteToolRegistry` 需要 `pdo_sqlite` 扩展(绝大多数 PHP 发行版默认自带);没有它时可以用 `MemoryToolRegistry` 或自行实现 `ToolRegistryInterface`。 --- ## Editor:AI 代码编辑 `Ai\Editor\*` 提供一套「让 AI 改代码」的完整协议:把编辑器现场(当前文件、光标、选区、已打开文件、工作区规范)结构化后交给模型,模型返回可校验、可执行的编辑动作。 ```php use Ai\Editor\EditContext; use Ai\Editor\EditProtocol; use Ai\Editor\EditExecutor; use Ai\Editor\EditAction; // 1. 组装编辑上下文 $ctx = (new EditContext(FCPATH)) ->setFile('templates/default/index.php') ->setLanguage('php') ->setContent($fileContent) ->setCursor(['line' => 42, 'column' => 8]) ->setSelection(['start' => [...], 'end' => [...]], $selectedText) ->setOpenedFiles($openedFiles) ->setWorkspace($workspace); // Ai\Editor\Workspace,限定可写根目录与编码规范 // 2. 生成系统提示词(内含编辑协议说明)+ 上下文 JSON $system = EditProtocol::systemPrompt($ctx); $ctxJson = json_encode($ctx->toPromptJson(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $response = $ai->chat([ 'system' => $system, 'messages' => [['role' => 'user', 'content' => $message . "\n\n[CONTEXT]\n```json\n{$ctxJson}\n```"]], ]); // 3. 解析模型返回的编辑计划 $plan = EditProtocol::parse($response->getContent()); // 4. 校验并执行 $executor = new EditExecutor($workspace->getRoot()); // 越界路径会被拒绝 foreach ($plan->toArray()['actions'] as $a) { $action = EditAction::fromArray($a); if (!$action->validate()) continue; $abs = $executor->resolveAbsolute($action->file); // 路径安全解析 $newContent = $executor->computeContent(file_get_contents($abs), $action); file_put_contents($abs, $newContent); // 建议先备份 } ``` 配合 Agent 可实现三种工作模式:`plan`(只读规划)、`approval`(产出待人工审核的建议)、`auto`(自动写入并备份)。 --- ## 联网搜索 不少平台的模型能自己上网查资料再回答。各家的开关五花八门——有的是请求体顶层的一个布尔值, 有的要往 `tools` 里塞一个内置工具,还有的走插件系统。本库把它们归一成一个 `search` 配置: ```php $ai = AI::create([ 'model' => 'qwen-plus', 'api_key' => 'sk-xxx', 'search' => true, // 开启联网搜索 ]); echo $ai->chat('今天有哪些重要新闻?')->getContent(); ``` 换个平台只改 `model`,`search` 这行不动: ```php $ai->setConfig(['model' => 'claude-sonnet-4-20250514', 'api_key' => 'sk-ant-xxx']); $ai->setConfig(['model' => 'glm-4-plus', 'api_key' => 'xxx']); ``` ### 细化配置 `search` 传数组可以调细节。省略 `enable` 即视为开启: ```php $ai->setConfig([ 'search' => [ 'enable' => true, 'max_uses' => 5, // 单次请求最多搜几次 'count' => 10, // 返回结果条数 'query' => 'PHP 8.5 新特性', // 强制指定搜索词,不指定则由模型自己拟 'recency' => 'week', // 时效:hour / day / week / month / year 'forced' => true, // 强制搜索,不让模型自行判断要不要搜 'citation' => true, // 正文里带引用角标 'sources' => true, // 返回搜索来源列表 'allowed_domains' => ['wikipedia.org'], // 只搜这些域名 'blocked_domains' => ['spam.com'], // 不搜这些域名(与上一项互斥) ], ]); ``` **平台不支持的细项会被静默忽略**,不影响搜索本身开启。这是刻意的:统一层承诺的是 「搜索会开」,不是「每个细节都能在每个平台生效」。下表是各项的实际落点: | 统一配置 | Claude | 通义千问 | 智谱 GLM | Kimi | 文心一言 | OpenRouter | Perplexity | |---------|--------|---------|---------|------|---------|-----------|-----------| | `max_uses` | `max_uses` | — | — | — | — | — | — | | `count` | — | — | `count` | — | `search_number` | `max_results` | — | | `query` | — | — | `search_query` | — | — | — | — | | `recency` | — | — | `search_recency_filter` | — | — | — | `search_recency_filter` | | `forced` | — | `forced_search` | `require_search` | — | — | — | — | | `citation` | 总是开 | `enable_citation` | — | — | `enable_citation` | — | — | | `sources` | — | `enable_source` | `search_result` | — | `enable_trace` | — | `return_related_questions` | | `allowed_domains` | ✅ | — | 仅首个 | — | — | `include_domains` | ✅ | | `blocked_domains` | ✅ | — | — | — | — | `exclude_domains` | 加 `-` 前缀 | 几处需要留意的差异: - **Claude** 的引用是常开的,没有开关;`allowed_domains` 与 `blocked_domains` 同时传会被平台判为 400,本库在发请求前就会拦下并报错。 - **智谱** 的 `search_domain_filter` 官方类型是字符串而非数组,多个域名只会取第一个。 - **智谱** 没有「一小时内」这一档,`recency => 'hour'` 会并到 `oneDay`。 - **Perplexity** 的 Sonar 系模型**本来就是联网的**,不存在开不开;`search` 在这里 只用来传过滤条件。 - **Kimi** 的内置搜索走的是 tool_calls 流程——模型只生成搜索参数,需要客户端回填结果 对话才会继续。所以它必须配合 Agent 循环使用,单发一次 `chat()` 只会拿到一个工具调用: ```php $ai->setConfig(['model' => 'kimi-k2-0905-preview', 'search' => true]); $agent = new \Ai\Agent\Agent($ai); // ✅ 用 Agent $agent->run([['role' => 'user', 'content' => '今天有哪些重要新闻?']]); echo $agent->lastText(); ``` ### 哪些平台支持 ```php print_r(\Ai\Helpers\Protocols::withWebSearch()); // ['claude', 'qwen', 'ernie', 'zhipu', 'moonshot', 'perplexity', 'openrouter'] \Ai\Helpers\Protocols::supportsWebSearch('deepseek'); // false ``` **没列进来的平台,配了 `search` 会直接抛 `ConfigException`**,而不是静默忽略。 静默忽略在这里是最糟的选择:用户拿到的是一个「答得挺像样、但其实没上网」的回复, 内容陈旧却毫无征兆,往往要等到发现模型说的是去年的事才察觉。 需要特别说明的两个: - **OpenAI** 的 Chat Completions 端点没有联网开关,联网搜索只在 Responses API 或 `gpt-5-search-api` 这类专用搜索模型上提供。本库走的是 Chat Completions, 所以 `openai` 协议不声明支持。 - **通义千问 / 智谱 / Kimi 的 Anthropic 兼容端点**(`qwen-anthropic` 等)同样不支持。 那些网关只翻译 Anthropic 的**请求格式**,Anthropic 的 web_search 是 Anthropic 自己的 服务端能力,不会随协议格式一起过来。要在这些平台上联网,请改用它们的 OpenAI 兼容协议。 ### 平台私有参数:用 `extra_body` 统一配置只收各家都有的语义,平台独有的参数(通义的 `search_strategy`、 智谱的 `search_engine`、OpenRouter 的 `engine` 等)不进 `search`,用 `extra_body` 直接写: ```php $ai->setConfig([ 'search' => ['forced' => true], 'extra_body' => ['search_options' => ['search_strategy' => 'max']], ]); ``` `extra_body` 在请求体顶层做合并,**同名字段会整体覆盖** `search` 生成的结果—— 上例中最终发出的 `search_options` 只有 `search_strategy`,`forced_search` 会被顶掉。 要同时用两者,把所有子字段都写进 `extra_body`。 库对某平台的判断有误或过时时,`extra_body` 也是逃生口:它绕过全部声明检查, 可以直接发平台原生的搜索参数。 ### 来源与引用 平台界面上那串带角标、点开能看原文的引用,API 其实也发回来了——只是各家放在不同字段、 结构完全不同(Claude 挂在 text 块上,OpenAI 系叫 `annotations`,Perplexity 是顶层 `search_results`,Gemini 是 `groundingMetadata`……)。本库把它们归一成两份列表, **不用关心平台差异,按需取用**: ```php $ai = AI::create(['model' => 'claude-sonnet-5', 'api_key' => 'sk-ant-xxx', 'search' => true]); $resp = $ai->chat('2026 年世界杯冠军是谁?'); echo $resp->getContent(); // 来源:平台检索到的网页 foreach ($resp->getSources() as $s) { echo "[{$s['index']}] {$s['title']} {$s['url']}", $s['cited'] ? '' : '(未被引用)', "\n"; } // 引用:正文里哪段话出自哪里 foreach ($resp->getCitations() as $c) { $said = mb_substr($resp->getContent(), $c['start'], $c['end'] - $c['start']); echo "「{$said}」← {$c['url']}\n"; } ``` 两份列表的字段: | 来源 `getSources()` | 说明 | |---|---| | `index` | 编号,与正文角标 `[1]` 对应;平台没给就按顺序从 1 编 | | `url` / `title` | 网页地址与标题 | | `snippet` | 摘要或正文片段,平台没给为空串 | | `site_name` / `published_at` | 站点名、发布时间(平台原样字符串,不解析) | | `cited` | 是否被回答实际引用——区分「用上了」和「只是搜到了」 | | `raw` | 平台原始条目 | | 引用 `getCitations()` | 说明 | |---|---| | `url` / `title` | 出处 | | `cited_text` | 来源里被引用的原文,平台没给为空串 | | `start` / `end` | 在 `getContent()` 里的区间,**按 UTF-8 字符计**,可直接喂 `mb_substr()`;未知时为 `null` | | `source_index` | 对应 `getSources()` 的数组下标(从 0 起) | | `type` | 平台原始类型(如 `web_search_result_location`、`url_citation`);从正文角标解析出的为 `marker` | | `raw` | 平台原始条目 | 各平台的实际情况(均以官方文档为准): | 平台 | 来源 | 引用 | 说明 | |---|---|---|---| | Claude | ✅ `web_search_tool_result` | ✅ 带 `cited_text` | 区间是被引用的那段 text 块 | | OpenRouter | 由 `annotations` 去重得到 | ✅ `annotations` | 实测多数模型给的区间是 0-0,库改用正文里 `[域名](url)` 链接的位置;定位不到的只算来源(`cited` 为 false) | | Perplexity | ✅ `search_results` | ✅ 正文 `[1]` 角标 | 区间指向角标本身。Sonar 接口官方公告 2026-09-27 停止支持 | | 文心一言 | ✅ `search_results` | ✅ 正文 `^[1]^` 角标 | 需 `sources` / `citation` 开启;检索到非公开网页时平台不返回 | | 智谱 GLM | ✅ `web_search` | ✅ 正文 `[ref_1]` 角标 | 需 `'sources' => true`。平台默认的 `search_prompt` 要求模型不标出处,实测正文不带角标;在自定义 `search_prompt` 里要求按 `[ref_1]` 标注时可解析 | | 通义千问 | ❌ | ❌ | 官方明确:OpenAI 兼容端点不返回来源与角标,仅 DashScope 原生协议支持 | | Kimi | ❌ | ❌ | 搜索走工具调用流程,平台不返回来源 | 另外两处兼容: - **Gemini 原生 `generateContent` 响应**的 `groundingMetadata` 也能解析(Gemini 协议的 `parseResponse()` 两种结构都认;平台给的是字节偏移,已换算成字符)。本库的 Gemini 对话 走 OpenAI 兼容端点,官方未在该端点提供搜索,所以 `gemini` 不在上面的支持列表里。 - **xAI** 正文里的 `[[1]](url)` 行内引用会被识别,直接取链接里的 URL。 以上 Perplexity、智谱 GLM、Gemini 原生结构、OpenRouter(DeepSeek / Grok / Perplexity / Qwen / GLM / Kimi) 已经过真实接口实测,见 `tests/live/citations_live_test.php`。 **流式同样可用**,分片里的来源与引用会边收边记,结束后 `getSources()` / `getCitations()` 与非流式结果一致。`stream_end` 事件的 `data` 里也会带上 `sources` / `citations`(去掉了 `raw`,方便直接下发前端);没有来源时不出现这两个键,报文与旧版本完全一致。 没开搜索或平台没返回时,两个方法都返回空数组,不需要判空。 需要平台独有字段时,`raw` 里是原始条目,完整响应仍可用 `getRaw()` 取。 ### 与 `Ai\Tools\HttpFetch` 的区别 两者都能让模型用上网页内容,但不是一回事: | | `search` 配置 | `Ai\Tools\HttpFetch`(见下一节) | |---|---|---| | 谁在联网 | 平台的服务器 | 你的 PHP 进程 | | 计费 | 平台按次收搜索费 | 只有 token 费用,流量走你的服务器 | | 能力 | 搜索引擎检索 | 抓取你指定的 URL | | 可控性 | 只能给过滤条件 | 完全可控,含 SSRF 防护 | | 支持范围 | 仅上表 7 个平台 | 所有平台 | 需要「模型自己决定搜什么」用 `search`;需要「读这几个我指定的页面」用 `HttpFetch`。 两者可以同时开。 --- ## Tools:安全网页抓取 让模型联网读网页时,最大的风险是 SSRF。`Ai\Tools\HttpFetch` 内置纵深防御: - 仅允许 `http`/`https`,拒绝带 `user:pass@` 的 URL - 端口白名单(默认 80/443) - 解析主机的所有 A/AAAA 记录,**任一 IP 落在私有/保留/回环/链路本地/云元数据段即整体拒绝** - 用 `CURLOPT_RESOLVE` 把连接钉死到已校验 IP,防 DNS rebinding - 不自动跟随重定向,逐跳重新校验 - 超过 `max_bytes` 立即中断;校验 TLS;不带 Cookie;不走站点代理 ```php use Ai\Tools\HttpFetch; use Ai\Tools\WebContent; $fetcher = new HttpFetch(['max_bytes' => 1500 * 1024, 'timeout' => 15]); $res = $fetcher->fetch($url); // $res = ['ok'=>bool, 'status'=>int, 'content_type'=>string, 'final_url'=>string, 'bytes'=>int, 'body'=>string, 'error'=>string] if ($res['ok']) { // 按需渲染成模型友好的格式:text 纯文本 / md Markdown / source 原始源码 $text = WebContent::render($res['body'], $res['content_type'], 'md', 16000); } ``` 把它包成 Agent 工具即可让模型自主上网: ```php 'fetch_url' => [ 'description' => '抓取一个公网网页并返回正文,用于查证实时信息。', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'url' => ['type' => 'string'], 'format' => ['type' => 'string', 'enum' => ['text', 'md', 'source']], ], 'required' => ['url'], ], 'handler' => function ($input) use ($fetcher) { $r = $fetcher->fetch($input['url']); if (!$r['ok']) return 'ERROR: ' . $r['error']; return WebContent::render($r['body'], $r['content_type'], $input['format'] ?? 'md'); }, ], ``` --- ## Agent 多模态与附件对话 让 Agent 收得下图片和 PDF:用户在对话里上传的附件、Agent 自己 `read_file` 读到的图片,都能被模型真的看到。主模型不支持视觉时,自动交给已配置的视觉模型处理,用户无感。 ```php use Ai\Agent\Media\Attachment; $agent->chat('这张截图里报的是什么错?', [ Attachment::fromPath($_FILES['shot']['tmp_name']), ]); ``` 三条设计约定,都是踩过坑之后定下来的: 1. **对话历史里存引用,不存 base64。** 一张 5 MB 的图 base64 后约 6.67 MB,写进会话 JSONL 之后每次 load/save 都要搬运这几 MB。 2. **模型看不了图时明确告知,绝不伪装。** 不用 `[图片: x.png]` 这种占位符——它看起来像「图已附上」,会诱导模型写出「从图片可以看出……」。 3. **附件真的进对话历史。** 第 3 轮再问「刚才那张图里的数字是多少」,图还在;会话恢复之后也还在。 ### 快速开始 ```php use Ai\AI; use Ai\Agent\Agent; use Ai\Agent\Media\Attachment; $ai = new AI(['api_key' => $key, 'platform' => 'openai', 'model' => 'gpt-4o']); $agent = (new Agent($ai))->setWorkdir(APP_PATH); $result = $agent->chat('这张图里的四位数字是多少?', [ Attachment::fromPath('/uploads/shot.png'), // 本地文件 'https://example.com/b.jpg', // 字符串也行,自动识别路径 / URL ['path' => '/uploads/doc.pdf'], // 数组写法 ]); ``` `Attachment` 三个工厂方法: | 方法 | 用途 | 安全策略 | |------|------|---------| | `fromPath($path)` | 本地文件 | realpath 解析(挡 symlink 逃逸)、可选目录白名单、MIME 内容嗅探 + 扩展名交叉校验 | | `fromUrl($url)` | 远程文件 | **强制走 `HttpFetch`**,复用其 SSRF / DNS rebinding / 私网与云元数据地址 / 重定向逐跳校验 | | `fromBase64($data, $mime, $name)` | 已在内存里的字节 | 接受原始二进制、base64、或完整 `data:` URI | 字节是**惰性读取**的:`fromPath()` 只记路径,直到真要落库那一刻才读盘。一次传十个附件不会因为构造对象就把十个文件全读进内存。 支持的类型:图片(png / jpg / gif / webp / bmp)与 PDF。把 `.php` 改名成 `.png` 传上来会在 MIME 交叉校验这关被拦下。 ### 附件存哪儿 对话历史里只有引用: ```json { "type": "agent_media", "media": "image", "mime": "image/png", "name": "screenshot.png", "bytes": 20480, "ref": "media://6a9e37f47d0572c332fb73b2" } ``` 真实字节归 `MediaStore` 管,默认落在 `AgentHome` 的媒体目录(与 sessions 同级,沿用同一套项目隔离与 userId 隔离规则)。发请求的那一刻才由 `MediaResolver` 解析成 base64。 要接对象存储就自己实现 `MediaStoreInterface`: ```php $agent->media(['store' => new MyS3MediaStore($bucket)]); ``` **媒体文件的清理责任在应用。** 库不做自动 GC——它不知道同一个 ref 还被哪些会话引用,自动删是不可逆的。提供两个显式接口: ```php $agent->mediaManager()->prune(30); // 清理 30 天前的 $agent->mediaManager()->gcByMessages($liveMessages); // 按还活着的引用回收 ``` ### 限额与安全 一个总限制是不够的:5 MB 单文件很合理,但十个一起发就是必然失败的请求,而且失败发生在平台侧、错误信息通常很难懂。所以是三级: ```php $agent->media([ 'max_attachment_bytes' => 5 * 1024 * 1024, // 单文件 'max_message_bytes' => 10 * 1024 * 1024, // 单条消息全部附件之和 'max_attachments_per_message' => 8, // 单条消息附件个数 'max_request_bytes' => 20 * 1024 * 1024, // 单次模型请求的媒体总量 'allowed_paths' => [APP_PATH . '/uploads'], // 可选的目录白名单 ]); ``` 任一附件不合法则整批失败,不会留下没人引用的孤儿文件。 ### 工作区图片:`read_file` 直接读图 ```php $agent->setTools(['read_file' => new ReadFileTool(new PathSafety(APP_PATH))]); $agent->chat('读一下 error.png,告诉我报的什么错'); ``` 工具只负责说「我发现了一张图,引用在这里」,**不决定**它以什么协议格式进入对话——那是运行时与协议层的事。所以 `ReadFileTool` 完全不需要知道当前跑的是 Claude 还是 OpenAI。 媒体会被拼进**同一条** user 消息、排在 `tool_result` 之后。这一点两个协议家族都成立:Claude 的 user 消息本来就允许 `tool_result` 块后面跟 image 块;OpenAI 家族则由库自动拆成「若干条 `role:tool` + 随后一条 `role:user`」,图片落到那条能接受它的 user 消息里。**不能**另起一条 user 消息——Anthropic 不接受连续两条同角色消息。 ### 自动模态路由 主模型是纯文本时,配一个能看图的平台,剩下的库自己处理: ```php $agent->platforms([ 'gemini' => [ 'api_key' => $geminiKey, 'platform' => 'gemini', 'model' => 'gemini-2.5-flash', 'proxy' => 'http://127.0.0.1:8993', // 需要代理时 'modalities' => ['vision'], ], ]); $agent->multimodal(['mode' => 'auto']); // 默认就是 auto,这行可以省 $agent->chat('图里那个四位数字是多少?', ['/uploads/shot.png']); // → 主模型看不了图 → 自动交给 Gemini 读成文字 → 主模型据此回答 ``` 四种模式: | 模式 | 行为 | |------|------| | `auto`(默认) | 当前模型确定支持 → 直发;不支持或不确定 → 找视觉模型 describe;找不到 → 明确告知不可用 | | `native` | 强制直发当前模型,不路由 | | `describe` | 总是先让视觉模型把文件读成文字,再交给主模型 | | `switch` | 本轮**这一次模型调用**换成视觉模型;tools / permissions / session / memory 一律不变 | 默认 `auto` 而不是 `describe`:主模型本来就支持视觉时,describe 会白花一次调用,还把原图降级成了别人写的文字。 **描述是派生上下文,不替代原图。** 它被记在媒体块的 `description` 字段里,原 `ref` 原样保留。两个好处:以后换成视觉模型还能重新看原图;同一张图在后续轮次里不会被反复描述。 **回填时注明来源**,主模型收到的是: ```text [图片「shot.png」的内容描述 —— 由 gemini-2.5-flash 查看后生成,当前模型无法直接查看该文件] 图中是一个红色矩形按钮,上面用白色大写字母写着 SUBMIT,下方有黑色数字 7431。 ``` 不写来源的话,主模型会把二手描述当一手观察,在描述有偏差时给出过度自信的结论。 要用自己的方式构造视觉模型的连接(比如走内网网关): ```php $agent->modalityRouter()->setAiFactory(function ($model, $config) { return new AI(['api_key' => $config['api_key'], 'model' => $model, 'base_url' => 'http://gateway.internal/v1']); }); ``` 路由决策会发 `modality_route` 事件,带 `decision` / `provider` / `notes`。 ### 模型能力表 判断「这个模型能不能吃图片」用的是独立的能力表,**不是** `BaseModel::$features`——那份数据既有假阳性(`CustomModel` 对 39 个平台一律乐观声明 vision)又有假阴性(`Gemini25Pro` 只声明了 chat)。 匹配的是**模型名**,不按协议家族推断:本库 40 个平台里大量是 OpenAI 兼容的中转与自建网关,同一个 `/v1/chat/completions` 后面可能是 GPT-4o,也可能是纯文本的 7B 模型。 ```php // 查一下 $agent->capabilities()->supports('gpt-4o', 'image'); // true $agent->capabilities()->supports('deepseek-chat', 'image'); // false $agent->capabilities()->supports('my-private-vl', 'image'); // null —— 不知道 // 自己补 $agent->multimodal(['capabilities' => [ 'my-private-vl' => ['input' => ['image' => true]], 'vl-*' => ['input' => ['image' => true]], // 支持通配 ]]); ``` **`null` 与 `false` 是两回事**:`null` 是「不知道」,`false` 是「确定不支持」。混淆这两者会导致两种错误——把图片发给看不了图的模型(换回一个难懂的错误码),或者放着能用的模型不用。内置表只填有把握的,拿不准的一律留 `null`。 未知能力时的行为分两种口径: - 要不要**路由到**某个能力未知的模型 → 保守,不选(`unknown_policy`,默认 `conservative`) - 当前模型能力未知,但**手上有**确定能看图的模型 → 优先路由过去;实在没有外援才乐观直发 后一条是实测逼出来的:某些平台的纯文本模型收到图片只返回一个 `HTTP 510 Model Request Error`,既不说是哪个字段的问题也不说是不支持。 ### 没有视觉模型时会怎样 不会静默忽略,也不会假装看到。模型收到的是一句事实陈述: ```text [系统提示] 用户上传了图片「shot.png」,但当前模型无法查看图片内容。 请不要臆测其内容;如果需要,请告诉用户换用支持该类型的模型。 ``` 实测下模型的回答是「我看不到这张图片。当前模型无法查看图片内容。」——没有编造。 ### API 速查 | 类 | 作用 | |---|---| | `Ai\Agent\Media\Attachment` | 附件入口:`fromPath` / `fromUrl` / `fromBase64` | | `Ai\Agent\Media\MediaReference` | `media://` 值对象与 `agent_media` 块 | | `Ai\Agent\Media\MediaStoreInterface` | 存储接口,接对象存储时自己实现 | | `Ai\Agent\Media\FileMediaStore` | 默认实现,原子写 + sidecar 元数据 + `prune`/`gc` | | `Ai\Agent\Media\MediaResolver` | 请求边界解析引用 + 单次请求媒体配额 | | `Ai\Agent\Media\MediaManager` | 校验 / 存储 / 解析的门面,三级限额 | | `Ai\Agent\Context\MessagePart` | `text` / `agent_media` 块的构造与识别 | | `Ai\Helpers\MediaTranslator` | 内部块 → Anthropic `image` / OpenAI `image_url` | | `Ai\Agent\Capability\Modalities` | 输入模态常量 TEXT / IMAGE / PDF | | `Ai\Agent\Capability\CapabilityRegistry` | 模型能力表,内置 + 平台声明 + 用户覆盖 | | `Ai\Agent\Capability\CapabilityResolver` | 三层合并,`null` / `false` 严格区分 | | `Ai\Agent\Capability\ModalityRouter` | 四种模式的路由决策与消息改写 | | `Ai\Agent\Agent::media()` | 限额、白名单、自定义存储 | | `Ai\Agent\Agent::multimodal()` | 路由模式、能力覆盖、`unknown_policy` | 可运行示例见 `examples_multimodal.php`(不联网、不需要 API Key)。 --- ## Memory:Agent 长期记忆 把一个 Markdown 文件当作 Agent 的持久记忆(类似 `CLAUDE.md`)。文件位置由业务层决定,库不认识任何具体路径。 ```php use Ai\Agent\Memory; $mem = new Memory(FCPATH . 'writable/agent/memory.md', 20000); // 第二参数:注入对话时的最大字符数 $block = $mem->forPrompt(); // 读取并截断,空记忆返回 '' if ($block !== '') { $system .= "\n\n# 长期记忆\n" . $block; } $mem->append('用户偏好深色主题'); // 追加一条 $mem->write($fullContent); // 覆盖整份 ``` --- ## 完整业务案例:批量 JSON 翻译 一个真实场景——把多语言词条按批交给 AI 翻译,要求模型严格返回 `{"记录ID":"译文"}` 的 JSON,并做失败重试、格式校验与结果回写。 ```php use Ai\AI; use Ai\Exceptions\AIException; // 1. 组装待翻译数据:{ 记录ID: 原文 } $translateData = []; foreach ($batch as $rec) { $translateData[(int)$rec['id']] = $rec['text_source']; } // JSON_UNESCAPED_SLASHES 很关键:否则

会被转义成 <\/p>, // 模型会把 \/ 当字面字符照抄进译文,导致译文出现错误转义 $dataJson = json_encode($translateData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $prompt = "把下面 JSON 中每个值从 {$from} 翻译成 {$to}," . "保持键不变、保留 HTML 标签,只返回 JSON:\n{$dataJson}"; // 2. 调用,带重试与 JSON 校验 $ai = new AI(); $ai->setConfig(['model' => $model, 'api_key' => $apiKey, 'max_tokens' => 1024 * 256]) ->setTimeout(300); $content = ''; for ($loop = 1; $loop <= 3; $loop++) { try { $content = trim($ai->chat($prompt)->getContent()); } catch (AIException $e) { log_message('error', 'AI翻译异常: ' . $e->getMessage()); break; } // 去掉模型可能包裹的 ```json ``` 围栏后校验 $content = preg_replace('@^\s*```(json)?\s*(.+?)\s*```\s*$@is', '$2', $content); if ($content !== '' && json_decode($content, true) !== null) break; $content = ''; } // 3. 回写 $result = json_decode($content, true); if (is_array($result)) { foreach ($result as $id => $text) { if (!isset($translateData[(int)$id]) || trim($text) === '') continue; update_translation((int)$id, trim($text)); } } ``` 实战经验: - **务必带 `JSON_UNESCAPED_SLASHES`**,并在收到结果后兜底 `str_replace('\\/', '/', $text)`; - 模型常把 JSON 包在 ```` ```json ```` 围栏里,解析前先剥离; - 按**总字符数**而不是条数切批(例如单批 10 万字符上限),超长的单条 HTML 单独成批; - 失败时把模型返回的原文一并记录下来,否则无从排查。 --- ## 扩展开发 ### 新增模型 ```php namespace Ai\Models\OpenAI; use Ai\Models\BaseModel; class GPT4Turbo extends BaseModel { protected $name = 'gpt-4-turbo'; // 发给平台的真实模型名 protected $platform = 'openai'; protected $protocol = 'Ai\\Protocol\\OpenAI'; // 复用协议 protected $endpoint = 'https://api.openai.com/v1/chat/completions'; protected $features = ['chat', 'stream', 'vision']; protected $config = ['max_tokens' => 4096, 'temperature' => 0.7]; } ``` 然后在 `AI::$modelMap` 中注册 `'gpt-4-turbo' => 'Ai\Models\OpenAI\GPT4Turbo'`。 需要特殊附件处理的模型,重写 `processAttachments(array $payload, array $attachments): array` 即可。 > 只是想临时接一个新模型/新接口,**不必写模型类**——直接用 `model` + `protocol` + `base_url` 配置即可,库会在运行时构造 `Ai\Models\CustomModel`。也可以自己 `new CustomModel([...])` 传给 `setModel()`。 **绝大多数新平台都是 OpenAI 兼容的**,继承 `Ai\Protocol\OpenAI` 改一个地址即可——库内 30 多个平台协议都是这么实现的: ```php namespace Ai\Protocol; /** * 某某云(OpenAI 兼容) */ class MyCloud extends OpenAI { public function defaultBaseUrl(): string { return 'https://api.mycloud.com'; } // 路径与 OpenAI 官方不同时才需要覆盖 public function chatPath(): string { return '/v2/chat/completions'; } public function modelsPath(): string { return '/v2/models'; } // 鉴权方式不是 Authorization: Bearer 时覆盖 buildHeaders() /** 常用模型:供后台离线渲染下拉框,也是拉取失败时的兜底 */ public function knownModels(): array { return ['my-model-pro' => 'MyCloud Pro']; } } ``` 然后在 `Ai\Helpers\Protocols::$map` 注册标识(顺带在 `$alias` / `$detect` 里加别名与模型名识别规则),`protocol => 'mycloud'` 就能用了。 **协议格式本身不同**(既不是 OpenAI 也不是 Anthropic / Gemini)时: 1. 实现 `Ai\Contracts\ProtocolInterface`:`buildRequest`、`parseResponse`、`buildHeaders`、`parseStreamChunk`、`isStreamEnd`、`listModels`; 可选实现 `defaultBaseUrl()` / `chatPath()` / `modelsPath()` 供自动组装端点,`use ModelCatalog` 获得常用模型清单与兜底能力; 2. 创建该平台的模型类,`$protocol` 指向新协议;或直接把协议类名传给 `protocol` 配置项(无需改动本库): ```php AI::create(['model'=>'x', 'protocol'=>'App\\Protocol\\MyApi', 'base_url'=>'https://api.my.com']); ``` 3. 在 `AI::$modelMap` 注册模型标识(可选,仅为提供快捷标识)。 传输层 `Ai\Transport\CurlTransport` 与协议无关,通常无需改动。 --- ## 架构 ``` php-ai/ ├── src/ # 源代码(PSR-4 命名空间 Ai\) │ ├── AI.php # 主入口:配置、模型解析、对话、流式、回调 │ ├── Agent/ # Agent 循环 + 长期记忆 │ │ ├── Media/ # 附件:Attachment / MediaStore / Resolver(历史存引用,不存 base64) │ │ ├── Capability/ # 输入模态能力表与模态路由(auto / native / describe / switch) │ │ ├── Registry/ # Tool Registry:SQLite(FTS5) / Memory / 执行器 / 风险策略 / Controller 网关 │ │ ├── Indexer/ # 扫描器:PHPDoc / Attribute 解析、Reflection Schema、增量索引 │ │ └── Discovery/ # 按需发现:搜索、权限过滤、接进 Agent 的三个运行时工具 │ ├── Cli/ # ClaudeCode 一次性调用 / ClaudeCodeSession 常驻双工会话 + 响应对象 │ ├── Contracts/ # 接口定义:Model / Protocol / Transport / AIResponse │ ├── Editor/ # AI 代码编辑:上下文 / 协议 / 动作 / 执行器 / 工作区 │ ├── Exceptions/ # AIException / ConfigException / RequestException / ProcessException │ ├── Helpers/ # AIFile(附件封装)、Endpoint(端点解析)、Protocols(协议注册表) │ │ # Headers(请求头合并)、Tools(工具调用格式归一)、Log(可注入日志) │ ├── Models/ # 模型层:各平台模型的名称、端点、能力、默认配置 │ │ ├── BaseModel.php │ │ ├── CustomModel.php # 通用模型:任意模型名 + 手选协议 + 自定义接口地址 │ │ ├── OpenAI/ Claude/ Gemini/ DeepSeek/ │ ├── Protocol/ # 协议层:40 个平台协议 │ │ # ModelCatalog.php 常用模型清单 + 拉取失败兜底(trait) │ │ # OpenAI / Claude / Gemini 三种基础协议格式 │ │ # 国内:Qwen / Doubao / Ernie / Zhipu / Moonshot / Hunyuan / Spark / │ │ # MiniMax / StepFun / Yi / Baichuan / SenseNova / Zhinao / ModelArts … │ │ # 海外:Grok / Mistral / Cohere / Perplexity / Llama / Azure … │ │ # 聚合:OpenRouter / SiliconFlow / ModelScope / Groq / Together / Fireworks … │ │ # 本地:Ollama / LMStudio / VLLM │ ├── Response/ # 统一响应对象 │ ├── Tools/ # HttpFetch(SSRF 防护)、WebContent(格式化) │ └── Transport/ # cURL 传输层(含 SSE 解析、代理、超时) ├── autoload.php # PSR-4 加载器(不用 Composer 时引入) ├── composer.json ├── tests/ # 回归测试(纯 PHP,无需 PHPUnit、无需网络、无需 API Key) │ ├── smoke_test.php # 全部类可加载/可实例化、继承链签名兼容 │ ├── stream_test.php # 40 个协议 × 普通对话 / 流式 / token 统计 │ ├── tools_test.php # 工具调用跨平台一致性(同一段代码跑两个协议家族) │ ├── lib_test.php # 并发批量 / Memory 并发安全 / 计价 / 日志注入 │ ├── cli_test.php # CLI 参数渲染与命令注入防护 │ └── ssrf_test.php # SSRF 防护的全部已知绕过向量 ├── bin/php-ai # CLI:Agent Tool 索引与查询(php-ai index / tools / tools:search) ├── examples*.php # 使用示例(examples_platforms.php 为多平台接入示例) ├── LICENSE ├── README.md └── .gitignore ``` 设计上分四层:**模型层**声明「是什么」(名称、端点、能力),**协议层**负责「怎么说」(请求/响应/流式格式),**传输层**负责「怎么发」(cURL、代理、超时、SSE),**主入口**负责编排。新增平台只动前两层。 --- ## 扩展能力:图像 / 语音 / 视频 / 向量 > **当前进度** > > | 能力 | 状态 | > |------|------| > | 文本向量化 `embeddings()` | ✅ v1.15.0,35 个平台 | > | 图像生成 `images()` | ✅ v1.16.0 同步 / v1.20.0 异步与编辑 | > | 语音合成 / 识别 `audio()` | ✅ v1.17.0,二进制与 JSON-hex 两种形态已归一 | > | WebSocket `realtime()` | ✅ v1.18.0,讯飞语音(RFC 6455 纯 PHP 实现) | > | 视频生成 `video()` | ✅ v1.19.0,4 个平台异步任务已归一 | > > 未交付的能力调用时会抛 `UnsupportedCapabilityException` 并说明原因, > 不会静默返回空结果。对话(`chat()`)功能完全不受影响。 ### 入口 对话之外的能力都走子门面,与 `chat()` 共享同一份配置: `setProxy()` / `setRetry()` / `setTimeout()` / `setLogger()` 配一次,各能力自动生效。 ```php $ai = new AI(['api_key' => '...', 'model' => '...']); $ai->embeddings(); // 文本向量化 $ai->images(); // 图像生成 $ai->audio(); // 语音合成 / 识别(HTTP) $ai->video(); // 视频生成(异步任务) $ai->realtime(); // WebSocket 通道,默认关闭 ``` ### 先判断再调用 ```php use Ai\Helpers\Capabilities; if ($ai->supports(Capabilities::IMAGE)) { $img = $ai->images()->generate('一只在看书的猫', ['size' => '1024x1024']); $paths = $img->saveTo('/var/www/uploads'); // 返回实际写入的绝对路径 } $ai->capabilities(); // 当前模型支持的能力清单,如 ['embedding', 'image'] ``` 不判断直接调也可以,不支持时会抛出带原因的异常,而不是返回空值让你猜: ``` 当前模型使用的协议不支持「图像生成」能力。本协议目前只支持对话(chat) ``` ### 文本向量化(已可用) ```php $ai = new AI(['api_key' => '...', 'model' => 'text-embedding-3-small']); // 单条 $vec = $ai->embeddings()->create('这是一段文本')->getVector(0); // 批量:返回顺序**始终**与输入顺序一致 $res = $ai->embeddings()->create(['第一段', '第二段', '第三段']); $res->getVectors(); // [[...], [...], [...]] $res->getVector(1); // 第二段的向量 $res->getDimensions(); // 1536 count($res); // 3 $res->getUsage(); // ['prompt_tokens' => .., 'total_tokens' => ..] ``` **顺序一致是有代价换来的**:多数平台不保证响应里 `data` 数组的顺序与输入一致, 库会按每条返回的 `index` 归位。这类错位不会报任何错,只会让后续检索莫名其妙地不准。 #### 平台参数直接透传 ```php $ai->embeddings()->create($texts, [ 'dimensions' => 512, // OpenAI text-embedding-3-* 支持降维 'encoding_format' => 'base64', // 返回 base64 时库会自动解回 float 数组 ]); ``` 只归一 `model` / `input` 两个字段,其余原样透传给平台。 `dimensions` 这类参数各平台支持度不同,以对方文档为准——库不替你猜, 不支持的平台会直接返回它自己的错误信息。 #### 超长批量自动分批 各平台单次可提交的条数上限不同(OpenAI 上千条,部分平台只有一二十条), 且官方文档未必写明。默认**不分批**(一次发完,不无谓拆分请求), 需要时用 `batch_size` 指定: ```php $res = $ai->embeddings()->create($tenThousandTexts, ['batch_size' => 25]); // 自动分成 400 个请求,结果按原顺序合并,usage 逐批累加 ``` 分批时若某一批返回的向量数与提交的文本数对不上,会**当场抛异常**而不是继续—— 数量对不上意味着后面所有下标都会错位,静默继续比失败危险得多。 #### 支持情况 31 个协议声明支持向量化,路径由各自的对话路径同级推导 (`/v1/chat/completions` → `/v1/embeddings`,`/v4/chat/completions` → `/v4/embeddings`), 所以带前缀的网关、Azure、Gemini 兼容端点都自动正确。 Anthropic Messages 协议家族(`claude` / `qwen-anthropic` / `zhipu-anthropic` / `moonshot-anthropic` / `deepseek-anthropic`)**不支持**——Anthropic 没有向量化接口。 用这些协议调用会得到明确报错。 > 平台审计(v1.21.0 ~ v1.24.0)已逐个核对官方文档与端点实测。 > DeepSeek、商汤、Llama、Perplexity、Cerebras 经确认**没有**向量化接口,不再声明。 > 若你确知某平台可用,配 `embedding_endpoint` 即可绕过库的判断。 ### 图像生成(已可用) ```php $ai = new AI(['api_key' => '...', 'model' => 'gpt-image-1']); $img = $ai->images()->generate('一只在看书的猫', ['size' => '1024x1024', 'n' => 2]); $img->getUrls(); // ['https://...', 'https://...'] $img->getBase64(); // 平台返回 base64 时在这里 $img->getRevisedPrompt(); // 部分平台会改写提示词,原样回传 count($img); // 2 // ⚠️ 及时落地:各平台的图片 URL 都有有效期 $paths = $img->saveTo('/var/www/uploads', 'cat'); // ['/var/www/uploads/cat_1.png', '/var/www/uploads/cat_2.png'] ``` **URL 有效期是这里最容易踩的坑**:万相约 24 小时、硅基流动**只有 1 小时**。 把 URL 存进数据库,用户过一阵回来看就全是坏图。要长期保留必须调 `saveTo()` 落盘。 `saveTo()` 的目录**必须已存在**,不会自动创建——多模态接口常在循环里落盘, 路径拼错时自动 `mkdir` 会在磁盘上散落一堆空目录,等发现时已经很难收拾。 下载走库内的 `HttpFetch`(带完整 SSRF 防护),不是裸 `file_get_contents()`。 #### 各平台字段差异已归一 图像接口远没有对话那么统一。下面这些差异(据各平台官方文档 2026-08 核对) 库内已经处理掉,**调用方在所有平台上写法一致**: | 平台 | 实际字段 | 你仍然写 | |------|---------|---------| | 硅基流动 | `image_size` / `batch_size`,响应是 `images[]` | `size` / `n` | | xAI | `aspect_ratio` + `resolution`,没有 `size` | `size`(自动换算成最接近的比例档) | | 火山方舟(豆包) | `response_format: "base64"` | `response_format: "b64_json"` | | OpenAI `gpt-image-*` | 只返回 `b64_json`,不支持 `url` | 取 `getUrls()` 或 `getBase64()` 都行 | 平台私有参数(`seed`、`guidance_scale`、`watermark`、`negative_prompt` 等)原样透传, 不做映射——把所有平台的所有参数都归一,归一层会厚到没法维护。 #### 支持的平台与模型 模型清单**据各平台官方文档**登记(不是靠端点探测猜的): | 平台 | 模型 | |------|------| | OpenAI | `gpt-image-1.5`、`gpt-image-1`、`gpt-image-1-mini`、`dall-e-3`、`dall-e-2` | | **Gemini** | `gemini-3.1-flash-image`、`gemini-3.1-flash-lite-image`、`gemini-3-pro-image`、`gemini-2.5-flash-image` | | **讯飞星火** | 走独立的 TTI 接口(`maas-api.cn-huabei-1.xf-yun.com/v2.1/tti`),需 `app_id` | | 智谱 | `glm-image`、`cogview-4-250304`、`cogview-4`、`cogview-3-flash` | | xAI | `grok-imagine-image-quality`、`grok-imagine-image-2.0` | | 硅基流动 | `Kwai-Kolors/Kolors`、`Qwen/Qwen-Image-Edit`、`Qwen/Qwen-Image-Edit-2509` | | 阶跃星辰 | `step-1x-medium` | | 火山方舟(豆包) | `doubao-seedream-5.0-lite`、`doubao-seedream-4.5`、`doubao-seedream-4.0`、`doubao-seedream-3.0-t2i` | | 通义万相 | 异步任务式,见下节 | ```php $ai->images(); // 子门面 (new \Ai\Protocol\Zhipu())->knownImageModels(); // 取某协议的图像模型清单 ``` **通义(qwen)走异步接口**:其 OpenAI 兼容模式没有同步文生图(实测 404), 万相是「提交任务再轮询」,用 `generateAsync()`,见下节。 > 某个平台是否真的开通了这个接口以对方文档为准——未开通时你会收到该平台自己的 404, > 而不是库的猜测。库的判断有误或过时时,配 `image_endpoint` 即可绕过。 #### 异步文生图(通义万相) 通义万相的文生图是**提交任务再轮询**,一次请求拿不到图。这类平台上 `generate()` 会明确报错并指向 `generateAsync()`——返回一个「成功但没有图」的 响应是最坏的做法,调用方拿到空结果完全不知道去哪找原因。 ```php $ai = AI::create(['protocol' => 'qwen', 'model' => 'wan2.2-t2i-flash', 'api_key' => '...']); $task = $ai->images()->generateAsync('一只在看书的猫', ['size' => '1024x1024', 'n' => 2]); $db->save(['task' => json_encode($task->toArray())]); // ……稍后 $task = AsyncTask::fromArray(json_decode($row['task'], true), $ai); if ($task->refresh()->isSucceeded()) { $task->getResult()->saveTo('/var/www/uploads'); } ``` 和视频任务共用同一套 `AsyncTask`:不阻塞、可跨请求恢复、超时不算失败。 > 库内会把统一写法的 `size: "1024x1024"` 转成万相要的 `"1024*1024"`(星号)。 > 分隔符传错不会被容错,平台直接判为非法参数。 #### 图像编辑(图生图 / 局部重绘) ```php // 整图改写 $ai->images()->edit('/path/cat.png', '把背景换成星空')->saveTo('/var/www/uploads'); // 局部重绘:只改蒙版覆盖的区域 $ai->images()->edit('/path/cat.png', '去掉这只手', ['mask' => '/path/mask.png']); ``` 走 multipart 上传,与文生图**不是同一个端点**。只接受**本地文件**—— 远端图片请先用 `Ai\Helpers\Media::download()` 取回落盘,库不会顺手替你下载, 因为那需要 SSRF 防护,不该由上传逻辑代劳。 支持:OpenAI、阶跃星辰、xAI、智谱。 **硅基流动不支持**(实测无 `/images/edits` 路由,它把图生图并进了 `images/generations`,靠传 `image` 参数区分);**通义也不支持**(兼容模式实测 404)。 ### 语音合成与识别(已可用) ```php // 文本 → 音频 $ai = new AI(['api_key' => '...', 'model' => 'gpt-4o-mini-tts']); $ai->audio()->speech('你好世界')->saveTo('/tmp/hello.mp3'); // 带参数 $audio = $ai->audio()->speech('你好世界', [ 'voice' => 'sage', // 音色 'format' => 'wav', // 库内统一写 format,各平台字段名不同 'speed' => 1.2, ]); $audio->getBytes(); // 原始音频字节 $audio->getFormat(); // 'wav' $audio->getSize(); // 字节数 // 音频 → 文本 $text = $ai->audio()->transcribe('/tmp/record.wav', ['language' => 'zh'])->getText(); ``` #### 两种完全不同的响应形态,库内已归一 | 平台 | 实际返回 | |------|---------| | OpenAI / 硅基流动 / 阶跃星辰 / 智谱 | **二进制音频字节**(`Content-Type: audio/*`) | | MiniMax | **JSON**,音频在 `data.audio`,**hex 编码**(不是 base64) | 调用方两边都写 `speech()->saveTo()`,拿到的都是可以直接播放的文件。 **这里有两个不会报错的坑,库内都堵上了:** 1. **平台出错时回的是 JSON,不是音频。** 不判别就会写出一堆扩展名 `.mp3`、 内容是错误信息的文件,全程无报错。库内按响应的实际 `Content-Type` 判别, 拿到 JSON 一律当错误处理,`saveTo()` 会直接抛异常而不是写出坏文件。 2. **MiniMax 的 hex 不是 base64。** 两者都是可打印字符,用 `base64_decode` 去解 不报错,只表现为「文件存下来了但放不出声」。另外它的错误不体现在 HTTP 状态码上—— `base_resp.status_code` 非 0 才是失败,此时 HTTP 仍是 200,库内会检查这个字段。 #### 音色缺省 OpenAI 的 `voice` 是**必填**参数,不传直接 400。库内会补一个默认音色, 让 `speech('你好')` 开箱可用;你显式传了就以你的为准。 ```php (new \Ai\Protocol\OpenAI())->knownVoices(); // ['alloy','ash','ballad','coral','echo','sage','shimmer','verse','marin','cedar'] ``` > 这份音色清单据 OpenAI 官方 OpenAPI 规范(2026-08),**与早年文档已经不同**: > `fable` / `nova` / `onyx` 已不在枚举里,新增了 `marin` / `cedar`。 > 照着旧印象写音色名会直接 400。 #### 语音识别走 multipart 上传 `transcribe()` 只接受**本地文件**(路径字符串或 `AIFile` 实例)。 远端音频请先用 `Ai\Helpers\Media::download()` 取回落盘——库不会顺手替你下载, 因为那需要 SSRF 防护,不该由上传逻辑代劳。 #### 支持的平台与模型 模型清单据各平台官方文档登记: | 平台 | TTS 模型 | ASR | |------|---------|-----| | OpenAI | `gpt-4o-mini-tts`、`tts-1-hd`、`tts-1` | `gpt-4o-transcribe`、`whisper-1` 等 | | 硅基流动 | `FunAudioLLM/CosyVoice2-0.5B`、`fnlp/MOSS-TTSD-v0.5` | `FunAudioLLM/SenseVoiceSmall`、`TeleAI/TeleSpeechASR` | | 阶跃星辰 | `step-tts-mini`、`step-tts-2`、`step-tts-vivid` | ✅ | | 智谱 | `glm-tts` | `glm-asr-2512` | | MiniMax | `speech-2.8-hd`、`speech-2.8-turbo` 等 8 个 | ✖(形态不同,未接入) | | Cohere | ✖ | `cohere-transcribe-03-2026`(须显式传 `language`) | | Mistral / Groq / Together / Fireworks / OpenRouter / DeepInfra | ✅ | ✅ | **通义(qwen)暂不支持**:其 OpenAI 兼容模式没有语音接口(实测 404), 原生 DashScope 语音接口形态差异很大,暂未接入。 **讯飞只提供 WebSocket**,走 `$ai->realtime()`。 ### WebSocket 实时通道(已可用) 讯飞的语音能力**只提供 WebSocket**,没有等价的 HTTP 接口。本库把它纳了进来, 省得你为一个平台单独写一套 WS 对接。 ```php $ai = AI::create([ 'protocol' => 'spark', 'app_id' => '<控制台的 APPID>', 'api_key' => ':', // 冒号拼接 ]); // 必须显式启用 WebSocket 通道 $ai->realtime()->useWebSocket()->speech('你好世界')->saveTo('/tmp/hello.mp3'); $text = $ai->realtime()->useWebSocket()->transcribe('/tmp/record.wav')->getText(); ``` #### 为什么必须显式调用 `useWebSocket()` 通道默认是 `null`,不显式启用就不会建立任何连接。这不是啰嗦—— WebSocket 是长连接,超时语义、错误形态都和普通 HTTP 请求不同, **握手成功之后**仍可能因帧格式问题静默挂死(服务端既不回数据也不发 close)。 这种行为差异不该在你不知情时自动发生。 不启用直接调用会得到明确提示,而不是含糊的失败: ``` 未指定实时通道协议。当前平台的语音能力只能通过 WebSocket 访问, 请显式调用 ->useWebSocket() 启用。…… ``` #### 零新增依赖 RFC 6455 客户端是纯 PHP 实现的,只用到 `stream_socket_client` / `stream_socket_enable_crypto` / `random_bytes` / `pack` 这些**核心函数**, 不需要 `ext-sockets`,也不引入任何 composer 包。 `wss://` 需要 `ext-openssl`(绝大多数环境都有)。缺失时会给出明确提示, 不会变成一个难懂的连接错误。已在 `composer.json` 的 `suggest` 里声明。 #### 凭据与 HTTP 接口不同 讯飞语音的鉴权**不走请求头**:用 APIKey/APISecret 算 HMAC-SHA256 签名后拼进 URL 查询串。 签名带时间戳且有效期很短,**每次连接都要重算**,不能缓存 URL。另外还需要 `app_id`, 是 APIKey/APISecret 之外的第三个凭据。这些库内都处理好了,你只要把三个值配上。 #### 语音识别要的是裸 PCM 讯飞听写接受 16k / 16bit / 单声道的裸 PCM。传 `.wav` 时库会自动剥掉文件头取出 `data` 块——直接把整个 wav 灌进去,头部那几十字节会被当成音频采样, 表现为开头一小段噪音或识别结果异常,**不会报错**,很难往文件格式上想。 其它容器格式(mp3 / m4a 等)请自行转码后再传。本库不做转码, 那需要引入 ffmpeg 之类的外部依赖。 #### 讯飞的 HTTP 语音接口不存在 `$ai->audio()->speech()` 在讯飞协议上会明确报「不支持」,并在错误信息里列出 本协议支持的能力(含「实时通道」),把你导向 `$ai->realtime()`。 比让请求打到一个不存在的 HTTP 路径、再拿一个含糊的 404 要好。 ### 视频生成 / 异步任务(已可用) 视频接口**无一例外都是异步任务式**,所以 `generate()` 返回的是任务而不是视频。 ```php // Web 请求里:提交后存库就结束,不阻塞 $task = $ai->video()->generate('日落的海边', ['duration' => 5, 'ratio' => '16:9']); $db->save(['task' => json_encode($task->toArray())]); // 定时任务 / 队列 worker 里:恢复并查询 $task = AsyncTask::fromArray(json_decode($row['task'], true), $ai); if ($task->refresh()->isSucceeded()) { $task->getResult()->saveTo('/var/www/videos/x.mp4'); } ``` #### 为什么不直接等结果 视频生成动辄几分钟。`wait()` 存在,但**不要在 Web 请求里调用**—— 它会占死一个 PHP-FPM worker,并发一上来整站就挂。那个方法是给 CLI 脚本和队列 worker 用的。 ```php // 只在 CLI / worker 里这么用 $task->wait(300, 3); // 最多等 300 秒,起始间隔 3 秒后指数退避 ``` #### 超时不是失败 `wait()` 超时**不抛异常**。任务在平台侧仍然在跑,抛异常会诱导你 `catch` 之后当失败处理, 白白丢掉一次已经付费的生成。超时后: ```php $task->isTimeout(); // true $task->isDone(); // false —— 所以 if ($task->isDone()) 的写法天然安全 $task->isFailed(); // false —— 不会被误当失败 $task->getMessage(); // 「任务仍在平台侧处理中……请保存 task_id「xxx」,稍后恢复后再查询」 ``` #### 四家平台的状态取值差异已归一 | 平台 | 状态字段 | 取值 | |------|---------|------| | 通义万相 | `task_status` | PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED | | 智谱 CogVideoX | `task_status` | PROCESSING / SUCCESS / FAIL | | 火山方舟 Seedance | `status` | queued / running / succeeded / failed | | MiniMax 海螺 | `status` | Preparing / Queueing / Processing / Success / Fail | | Gemini Veo | `status` | queued / in_progress / completed / failed | 库内统一成 `pending` / `running` / `succeeded` / `failed` / `timeout` 五种。 **平台新增了库里没见过的状态值时,按「处理中」对待而不是失败**—— 让用户的任务因为平台加了个新状态就全变失败,是最糟的降级方式。 #### MiniMax 是三段流程 其余三家是「提交 → 轮询」两步,MiniMax 多一步: ``` 提交 POST /v1/video_generation → task_id 查询 GET /v1/query/video_generation → status=Success,但只给 file_id 再取 GET /v1/files/retrieve → 真正的下载地址(有效期 9 小时) ``` 库内自动走完三步,调用方感知不到差别。另外 MiniMax 的失败**不体现在 HTTP 状态码上**—— `base_resp.status_code` 非 0 才是失败,此时 HTTP 仍是 200,库内会检查这个字段。 #### 支持的平台与模型 | 平台 | 模型(据官方文档) | |------|------| | 通义万相 | `wan2.7-t2v`、`wan2.7-t2v-2026-06-12` | | 智谱 | `cogvideox-3`、`cogvideox-2`、`cogvideox-flash`、`viduq1-*`、`vidu2-*` | | 火山方舟 | Seedance 系列 | | MiniMax | `MiniMax-Hailuo-2.3`、`MiniMax-Hailuo-02`、`T2V-01-Director`、`T2V-01` | | Gemini | `veo-3.1-generate-preview`、`veo-3.1-lite-generate-preview`、`gemini-omni-flash` | | Z.ai | 视频生成(据 z.ai 文档) | ⚠️ **结果 URL 都有有效期**(万相约 24 小时、MiniMax 仅 9 小时), 存 URL 进库很快就会失效,必须及时 `saveTo()` 落地。 ### 媒体结果要及时落地 多数平台返回的图片/视频 URL **有效期只有几小时到 24 小时**, 只把 URL 存进库,第二天就会全部失效。用 `saveTo()` 及时取回: ```php $img->saveTo('/var/www/uploads'); // 图片,返回路径数组 $audio->saveTo('/tmp/hello.mp3'); // 音频 $video->saveTo('/var/www/v.mp4'); // 视频,默认上限 64MB ``` 下载走库内带 SSRF 防护的抓取器(IP 钉死、逐跳重校验),不是裸 `file_get_contents()`。 目标目录**必须已存在**——库不会自动创建,避免路径写错时在磁盘上散落一堆空目录。 ### WebSocket 通道默认关闭 讯飞等平台的语音能力只提供 WebSocket。本库已集成, 但**必须显式启用**,因为 WS 是长连接,超时与错误语义都和普通 HTTP 请求不同: ```php $ai->realtime()->useWebSocket()->speech('你好世界'); ``` 不启用直接调用会得到明确提示,而不是含糊的连接失败。 ### 自定义网关 把 `base_url` 指向自建网关或中转服务时,图像/语音端点会**自动跟着走同一个网关**, 不会回落到官方地址(那意味着把数据发到你没指定的服务器上)。 需要单独指定某个能力的完整地址时,用 `<能力名>_endpoint` 配置项: ```php $ai = new AI([ 'api_key' => '...', 'base_url' => 'https://my-gateway.com/v1', 'image_endpoint' => 'https://another-host.com/v1/images/generations', // 可选 ]); ``` 这个配置项同时是**逃生口**:库对某平台能力的判断有误或过时时,配上 `<能力名>_endpoint` 就能绕过声明检查。若该协议族本身没有对应形态的解析器 (比如 Claude 之于图像),仍会报错,但会点明是哪一层挡住的,并建议改用 `protocol` + `base_url`。 ### 给自定义协议类的迁移说明 `ProtocolInterface` 在 v1.14.0 新增了 4 个能力方法。 - **继承内置协议的(`extends OpenAI` / `extends Claude` 等):无需任何改动。** README「扩展开发」一节教的就是这种写法,库内 38 个厂商协议类也都是这么写的。 - **裸实现接口的(`implements ProtocolInterface`):加一行即可。** ```php class MyProtocol implements ProtocolInterface { use \Ai\Protocol\Concerns\CapabilityDefaults; // ← 只需加这一行 // ……原有 6 个方法一字不用改…… } ``` --- ## 受限 PHP 环境:禁用了 exec / proc_open 生产环境(宝塔 / cPanel 面板的默认配置、共享主机)常在 php.ini 的 `disable_functions` 里禁掉 `exec`、`shell_exec`、`proc_open`。被禁的函数是**从函数表里消失**,调用时报的是: ``` Fatal error: Uncaught Error: Call to undefined function Ai\Agent\Workspace\exec() ``` `@` 抑制符挡不住,`try/catch` 也接不到。所以库里每个要跑外部命令的地方都先探测再调用, 探测不到就降级——**功能少一块,但 Agent 照常跑完**: | 能力 | 被禁后的行为 | |------|-------------| | git 工作区上下文(`WorkspaceManager`) | 退化成「非 git 仓库」,上下文里只剩 `cwd` | | 工作区快照(`WorkspaceSnapshot`) | 仍靠 `.git` 目录认仓库,分支 / 提交 / diff 取不到而留空 | | `bash` 工具(含后台模式) | 返回带原因的失败 `ToolResult`,不抛异常 | | 验证器(`php -l` / 单测 / git diff) | 记为**跳过**(通过 + 注明原因),不判失败——跑不了检查不等于代码有问题,判失败只会让 Agent 白白重试 | | MCP stdio 传输 | `open()` 抛 `RuntimeException` 并说明原因 | | 浏览器工具 | `isAvailable()` 与 `launch()` 均返回 false | | 后台派发(`BackgroundDispatcher`) | 从 `fork` 档退回 `sync` 同步执行 | | `rg` / `git` 二进制探测(`Shell::hasBinary`) | 一律 false,走纯 PHP 回退实现 | 自己的代码要判断时用 `Ai\Helpers\Shell`: ```php use Ai\Helpers\Shell; Shell::canExec(); // exec() 能用吗 Shell::canProcOpen(); // proc_open() 能用吗(管道、超时、后台进程都靠它) Shell::canRunCommand(); // 两条路通任意一条即可 Shell::hasFunction('shell_exec'); // 任意函数:function_exists + disable_functions / suhosin 黑名单 // 统一入口:exec / proc_open 哪个能用用哪个;两条都被禁时 code = -1、out = '' $res = Shell::run('git status --porcelain', '/path/to/repo'); // ['code' => int, 'out' => string] ``` 唯一没有降级路径的是 `Ai\Cli\ClaudeCode`——它本身就是「跑本机的 claude 程序」。 这类环境下请注入自定义执行器走 SSH,见上文「自定义执行器」。 ## 已知限制 - 会话历史存在内存里,进程退出即失,跨请求需用 `exportHistory()` / `importHistory()` 自行落库; - 流式下的工具调用已支持(分片会自动重组)。若模型声明本轮要调工具、却一个都重组不出来(说明该平台用了本库尚未覆盖的分片结构),会抛 `stream_tool_calls_unassembled` 异常而非静默返回空响应; - 流式输出只提取正文增量,推理模型的思维链(`reasoning_content` / `thinking` 块)不计入 `getContent()`,需要时从 `stream_chunk` 事件的 `raw` 字段自取; - 各平台的 `knownModels()` 常用模型清单是库内维护的静态快照,仅用于离线渲染下拉框与拉取失败兜底,最新可用模型请以 `listModels()` 的实时结果为准; - Azure OpenAI 只覆盖了新版 `/openai/v1` 路由,旧版「部署名 + api-version」路由需自行用 `endpoint` 配置完整 URL;AWS Bedrock、Google Vertex AI 因需要 SigV4 / OAuth 签名,暂未内置; - 自定义模型的 `supports()` 能力是乐观默认值(对方接口实际支持什么库无从得知),需要准确值时用 `features` 配置项自行声明; - `Ai\Protocol\Gemini::convertMessages()` 未被调用——Gemini 走的是 OpenAI 兼容端点,消息直接透传; - `chatBatch()` 并发批量不支持流式,也不走 `setAttachments()`(附件请写在各自 payload 里); - `cost()` 需自行传入价格表,库不内置各平台价格(价格变动频繁,内置必然过期); - `Ai\Cli\ClaudeCode` 依赖本机已安装 claude 程序;`proc_open` / `shell_exec` 被禁用的受限 PHP 环境需改用自定义执行器(如 SSH/SFTP)。 - 视频生成只覆盖**文生视频与首帧图生视频**; - 图像编辑只覆盖走 `/images/edits` 的平台;硅基流动与通义的图生图形态不同,未接入; - `wait()` 是阻塞的,只适合 CLI 与队列 worker;Web 请求里请用「提交存库 + 定时任务轮询」的写法; - WebSocket 通道只做「一次会话、发完收完就关」这一种模式,够覆盖讯飞 TTS/ASR;不支持并发多连接、自动重连、服务端模式与 permessage-deflate 压缩扩展; - MiniMax 的语音识别形态与 OpenAI 差异较大,暂未接入; - 向量化默认不分批,超出平台单次上限时需自行传 `batch_size`——各平台上限差异大且文档未必写明,库不预设一个「保险的小值」,那会让本可一次发完的平台白白多发几十个请求; - 视频生成一律异步,`AsyncTask::wait()` 会阻塞,不可在 Web 请求中使用;跨请求恢复需自行把 `toArray()` 的结果落库; - 火山方舟的视频模型清单与阶跃星辰的 ASR/音色清单**刻意留空**:其文档站是 JS 渲染的,未能查证。留空会回退到平台自己的模型列表接口,填错则会让用户拿着不存在的模型名去调。 --- ## 许可证 MIT License