Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 26, 2026PHP 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
特性
- 🔌 多平台:内置 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
composer require likun-mci/php-ai
require __DIR__ . '/vendor/autoload.php';
use Ai\AI;
手动引入(不使用 Composer)
库自带 PSR-4 加载器,把整个目录放进项目后引入一次即可:
require_once __DIR__ . '/autoload.php';
use Ai\AI;
快速开始
use Ai\AI;
$ai = AI::create([
'model' => 'gpt-4o',
'api_key' => 'sk-xxxxxxxxxxxxx',
]);
$response = $ai->chat('用一句话介绍人工智能');
echo $response->getContent(); // 回复文本
echo $response->tokens(); // 总消耗 tokens
换成国产平台,只需改配置——业务代码一行不用动:
$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 数组:
$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 传该平台控制台的密钥,其余写法所有平台完全一致:
// 换平台只改这两行,业务代码不动
$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即可。
// 官方新模型:识别出 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 |
// 用 GLM-4.6 跑工具调用:协议是 Claude 的,价格与密钥是智谱的
$ai = AI::create([
'protocol' => 'zhipu-anthropic',
'model' => 'glm-4.6',
'api_key' => $config['zhipu__api_key'],
]);
运行时查询平台与模型
$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、硅基流动等需要展示模型价格/能力标签的场景
真实用例——按平台分组拉取模型列表并本地缓存一周,供后台下拉框渲染:
$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) {
// 单平台失败不影响其它平台
}
}
配置项
$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:
$ai->setConfig([
'headers' => [
'Authorization' => null, // 删掉协议默认写入的 Bearer 头
'X-Api-Token' => 'abc123', // 换成对方要求的鉴权头
],
'extra_body' => ['enable_thinking' => false, 'safe_mode' => 1],
]);
链式接口:
$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://:
if (!empty($config['PROXY_SOCKS5'])) {
$ai->setProxy($config['PROXY_SOCKS5']);
} elseif (!empty($config['PROXY_HTTP'])) {
$ai->setProxy($config['PROXY_HTTP']);
}
自定义接口地址(第三方转发 / 中转 / 自建网关)
默认端点是各平台的官方地址。要接入第三方转发、中转或自建网关,有两种配置方式,优先级从高到低:
1. base_url —— 接口根地址,与协议官方路径智能拼接(最常用)
只要给出根地址即可,库会补上协议对应的路径;根地址自带的路径前缀会保留,与官方路径重叠的片段自动去重:
$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:
$ai = AI::create([
'model' => 'deepseek-chat',
'api_key' => 'sk-xxx',
'endpoint' => 'https://proxy.example.com/openai/deepseek/chat', // 原样使用
]);
endpoint 优先级高于 base_url;两者都不设置时使用官方默认端点,完全向后兼容。
OpenRouter 聚合中转
OpenRouter 是一个 AI 模型聚合平台,通过统一的 OpenAI 兼容接口访问 OpenAI、Claude、Gemini、DeepSeek、Llama 等多种模型。库已内置 openrouter 协议,开箱即用:
// 方式一(推荐):手选 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→ 协议推断为deepseekgoogle/gemini-2.5-pro-exp-03-25→ 协议推断为gemini
OpenRouter 返回的 usage 字段是 OpenRouter 统计而非原始模型统计(可能包含缓存命中标记等扩展字段),
$response->getUsage()原样透传。
通过 OpenRouter 查看实时模型状态:
$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:
// 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)可获取。
当前实际请求端点可随时查询:
echo $ai->resolveEndpoint(); // 返回按配置解析后的实际端点
Claude Code CLI 程序调用
Ai\Cli\ClaudeCode 直接调用本机安装的 claude 可执行程序(Claude Code CLI),与 Ai\Protocol\Claude(Anthropic HTTP API)互补。适合让 AI 直接操作工作区文件:读写文件、执行工具,配合 acceptEdits 权限模式实现"AI 改代码"。
快速开始
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 路径:自动检测 + 缓存 + 手动指定
$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 参数均可覆盖/新增/删除:
$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。
注意:
claudeCLI 没有--working-dir、--max-budget、--proxy、--theme这些参数(实测会被拒绝)。工作目录用setWorkdir()(内部转成cd ... &&),代理走环境变量HTTP_PROXY/HTTPS_PROXY。
常用参数的专用方法
除了通用的 setFlag(),常用选项都有带取值校验的专用方法:
$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() 直接拿数组:
$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:
$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()。
多轮会话续接
$res = $cli->chat('第一问');
$cli->setSessionId($res->getSessionId()); // 下一轮自动 --resume 续接
$cli->chat('第二问', ['reset' => true]); // 强制开启新会话
每次执行后若输出带新 session_id,会自动回写到内部,getSessionId() 始终是最新的。
自定义执行器(远程 / 容器化场景)
默认用 proc_open 本地执行。需要经 SSH/SFTP 在宿主机跑 claude 时(如 Docker 容器内 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 "<cmd>",实测 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 上的其它请求全部排队:
$cli->setSleeper(function ($sec) { \Swoole\Coroutine::sleep($sec); });
设置后,本地执行的轮询间隔、双工会话的事件泵与关闭流程都改走它,会话类的 stream_select 一并改成"轮询 + 让出",不再阻塞整个线程。
信息查询(版本 / 登录态 / 模型列表 / 额度用量)
不需要发起对话就能读取 CLI 侧的各类信息,不消耗模型额度:
$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 返回完整数据,适合后台下拉框渲染。
额度查询
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 的文本报告。
$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() 打开。
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 插件里的交互:
$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 没有收尾机会。
$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 缓冲),无需注册回调:
$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]
对应的前端消费代码:
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);
}
}
真实用例——后台聊天接口(含代理、附件、流式):
$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 会落到进程标准输出(日志文件),永远送不到客户端。这种场景注册回调,由调用方自己下发:
$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:
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() 同样可用:
$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 层按索引拼接
附件(多模态)
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,库自动维护(推荐)
$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 实例可服务多个会话:
$ai->setSessionId('user-1001')->chat('我喜欢喝咖啡');
$ai->setSessionId('user-2002')->chat('我喜欢喝茶'); // 与上一个会话互不干扰
$ai->setSessionId('user-1001')->chat('我喜欢喝什么?'); // 答"咖啡"
历史存在内存里,进程退出即失。跨请求要持久化就自己落库:
// 请求结束时存起来
$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 时库完全不介入,业务层自行拼接:
$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 数、按重要性摘要等)时用这种方式。
请求前后回调
$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() | 转数组 / 直接当字符串用 |
异常处理
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 临时故障。库默认已处理:
$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 客户端或在单元测试里注入假传输层时,替换整个传输层:
$ai->setTransport(new MyTransport()); // 实现 Ai\Contracts\TransportInterface 即可
并发批量:chatBatch()
批量翻译、摘要、打标签这类场景串行跑,总耗时是「单条 × 条数」,且每条都要重做一次
TLS 握手。chatBatch() 用 curl_multi 并发,总耗时约等于「最慢的一条 × 批次数」:
$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()。注入后拉取模型列表失败、流式回调异常等都会走你的日志:
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:
$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 拼)。
适合聊天界面:用户能一边看模型说话,一边看它去调工具。
$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 |
| 适合 | 单次任务、自己管历史 | 聊天界面、连续追问 |
$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() 说别的——等于告诉模型「那个别做了,改做这个」:
$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 提供了平台无关的取用接口:
$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
工具定义
$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: 异常信息 交给模型,不会中断循环。
运行
$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 对象:
// 兼容旧版:直接取最终文本
$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:
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()]);
对象工具与旧格式闭包工具可混用:
$agent->setTools([
new ReadFileTool(), // 对象
'get_weather' => [ // 闭包(旧格式)
'description' => '查询天气',
'input_schema' => [...],
'handler' => function (array $in) { return '晴'; },
],
]);
并行安全标记
实现 ParallelSafeToolInterface 可标记工具为并行安全,只读工具(read_file / grep / glob)可并行执行:
use Ai\Agent\Tool\ParallelSafeToolInterface;
class ReadFileTool implements AgentToolInterface, ParallelSafeToolInterface
{
public function isParallelSafe() { return true; }
}
不实现此接口的工具默认按「不可并行」处理,安全优先。
ClaudeCodeTools:内置工具工厂
Ai\Agent\Tools\ClaudeCodeTools 提供一键创建 Agent 完整内置工具集的能力,与 Claude Code CLI 默认工具集对齐:
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 阻塞在一次工具调用里:
// 模型侧: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 把它接给模型:
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)
默认反思靠固定关键词表判「任务完成没有」,换个措辞、说中文就判不出来。可换成让模型自己判:
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 读写,输出只给摘要:
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 兜底:
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,不破坏配对):
$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:
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 | 全部放行(⚠️ 不安全,不要用于不可信输入) |
$agent->setPermissionMode('plan'); // 只读模式
$agent->setPermissionMode('manual'); // 危险工具询问(默认)
$agent->setPermissionMode('bypass'); // 全部放行
规则匹配
在模式基础上,可以用 allowTool / denyTool 添加细化规则,优先级:deny > allow > 模式默认:
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);
也可通过快捷方式设置:
$agent->setPermissionMode('manual');
用户授权流程
当权限管理器返回 ask 时,Agent 暂停并发出 PERMISSION_DENIED 停止信号,等待业务层通过 approve() / deny() 响应:
$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 永远不会被拆散:
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 场景):
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() 的场合(自备完整消息的单次运行),续跑要自己把消息取回来:
$agent->run($messages);
$session = $store->load('user-abc-123');
if ($session) {
$agent->run($session->getMessages()); // run() 以会话里的消息为准
}
run()在会话存在时以会话消息为准,传进去的那份被视为它的旧副本。所以别拿run()追加新一轮提问——那句话会被覆盖掉且不报错。追加走chat()。
会话状态流转:running → paused(等待授权)→ running(恢复)→ completed / interrupted。
默认持久化与双根存储
自 v1.63 起,Agent 具备惰性自动持久化:不必显式挂 SessionManager / MemoryManager,只要表达了持久化意图(设置 setSessionId()、setUserId()、setAgentHome() 或 setProjectRoot() 之一),库就会自动把会话与记忆落到磁盘。
$agent
->setWorkdir('/var/www/project')
->setUserId('u_10086') // 多租户场景的硬要求:按用户隔离
->setSessionId(Agent::newSessionId());
$agent->chat('记住我偏好深色主题');
// 换个新实例,只要 userId + sessionId 一致就接得上
行为变化(升级须知):从本版本起,仅调用
setSessionId()也可能产生磁盘数据。若只想要纯内存行为,显式关闭:$agent->autoPersist(false);
双根存储:数据分两处落盘,语义不同——
| 根 | 位置 | 内容 | 权限 |
|---|---|---|---|
| 项目根 | <project>/.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——每个会话一个 <sid>.jsonl(每行一个事件)+ <sid>.state.json(非消息状态)。消息通常只增不减,追加一行的成本是 O(新增);只有 compact / rollback 时才整份重写(临时文件 + rename 原子替换)。整个读改写事务在 <sid>.lock 独占锁下完成,避免并发 lost update。显式 new FileSessionStore(...) 的旧用户不受影响,旧 <sid>.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 上下文膨胀:
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 继续执行自己的任务:
$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 分离保存:
// 获取某次运行的完整 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:
$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 用量与估算成本,超过预算自动停止:
// 快捷方式
$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 还管两个与花钱无关、但同样能让任务跑飞的维度:
$bm = new BudgetManager([
'maxDuration' => 300, // 墙钟上限:最多跑 5 分钟
'maxToolCalls' => 100, // 工具调用次数上限
]);
这两条是执行预算,与 token / 成本各管各的:一个卡在慢工具上的任务可能一个 token 都没多花,却已经占着 HTTP 连接跑了十分钟;一个反复 grep 的 Agent 可能 token 不多,工具调用已经上百次。超限时同样以 budget_exceeded 停止,summary()['reason'] 会说清是哪一项:
$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,不必自己去各个管理器里拼:
$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 次,指数退避):
$agent->getRuntime()->getLoop()->setModelRetries(3, 500); // 重试 3 次,首次退避 500ms
$agent->getRuntime()->getLoop()->setModelRetries(0); // 关掉
它重试的是同一个模型(治网关瞬时故障),与 setFallbackModels() 分工不同——后者是重试完还不行才换模型(治模型本身不可用)。鉴权失败、模型不存在(401 / 403 / 404 / 413)立即放弃,重试只是白等。每次重试都发 model_retry 事件。
万一重试用尽仍然失败,结果里会带上最后一段模型文本而不是空字符串——模型调用挂在第 N 轮,前 N-1 轮真做过的事不该跟着一起丢:
if ($result->getStopReason() === 'model_error') {
echo $result->getText(); // 挂掉之前它说过什么
echo $result->getExtra()['error']; // 为什么挂
echo implode(',', $result->getFilesChanged()); // 已经改了哪些文件
}
取消:让 Agent 停得下来
生产环境里任务需要能中途停下:用户按了停止、浏览器断开、任务超时。CancellationToken 让循环在安全点收手:
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 收尾,之后可以接着跑:
$result = $agent->getRuntime()->run($messages);
if ($result->getStopReason() === 'user_cancel') {
echo $result->getExtra()['reason']; // 取消原因
echo $result->getExtra()['resumable']; // 能不能从检查点恢复
}
长跑的工具(下载、全库扫描)可以在执行途中反复问「还要不要继续」:
$handler = function (array $in, ToolContext $ctx) {
foreach ($items as $item) {
if ($ctx->isCancelled()) {
return '已取消,处理了 ' . $done . ' 项';
}
// …
}
};
并行工具执行
当模型一次返回多个工具调用时,并行安全的工具(read_file / grep / glob)可同时执行,其余工具按顺序执行。默认关闭,需显式开启:
$agent->setParallelTools(true); // 启用并行
并行执行器默认按顺序执行(语义正确),在 Swoole / Workerman 协程环境可注入并行运行器实现真正并发:
$agent->getRuntime()->setParallelRunner(function (array $tasks) {
return \Swoole\Coroutine\parallel($tasks);
});
工具执行超时
一次工具调用(含重试等待)超过指定秒数后自动标记为超时,不再继续重试:
$agent->setToolTimeout(60); // 全局超时 60 秒
$agent->getRuntime()->setToolTimeout(30); // 或通过 Runtime 设置
超时判断时间点在每次执行完成和每次重试等待前,因此不会在工具执行中途强行中断(同步 PHP 无法打断用户态代码),但能阻止后续重试。框架内置的 BashTool 自带超时终止能力,可与全局超时配合使用。
超时工具的 StopReason 为 timeout,结果中的 error 包含超时详情。
模型降级
主模型服务级错误时,Agent 自动按序切换降级模型,保持上下文和工具状态不变:
$agent->setFallbackModels(['claude-sonnet', 'claude-haiku']);
降级发生后会发出 model_fallback 事件,包含当前使用的模型名与原始错误信息。所有降级模型均失败后,Agent 才以 MODEL_ERROR 停止。
验证管理(VerificationManager)
工具执行后自动运行验证命令(如 php -l),确保代码改动真实可用,而不是让模型"记得自己测试"。验证失败的错误信息会回填给模型,让它自行修复。
$agent->setVerification([
'edit_file' => ['php -l {file}'],
'write_file' => ['php -l {file}'],
'test' => ['vendor/bin/phpunit'],
]);
验证策略按工具名配置,支持多条命令;命令中的 {file} 占位符会替换为工具输入里的 file_path。验证通过 exec() 同步执行,退出码 0 视为通过,非 0 的错误输出回填给模型。
单独使用 VerificationManager:
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 写一个验证器:
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 | 统计改动规模,超出上限或碰了受保护路径即拦下 |
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/'],
]));
或者一次挂全部:
$agent->useDefaultVerifiers([
'test' => 'composer test', // 给了才挂 UnitTestVerifier
'workdir' => '/var/www/project', // 给了才挂 GitDiffVerifier
'maxFiles' => 10,
]);
SecurityVerifier 基于 token_get_all() 扫描,注释和字符串里的 eval 不会误报,
$db->exec() 这类方法调用也不算内置危险函数。确实需要某个函数时用 allow() 放行:
$sec = new SecurityVerifier();
$sec->allow('exec'); // 比关掉整个验证器精确
GitDiffVerifier 的用途是给「放手让 Agent 改代码」加护栏——一次动了 40 个文件、
几千行,通常不是任务要求的,而是模型跑偏了。目录不是 git 仓库时直接放行,不阻断流程。
工作区管理(WorkspaceManager)
跟踪 Agent 当前工作区的 Git 状态,让模型了解 cwd、分支、已修改文件等。状态按需刷新(默认 5 秒缓存),不会每轮都跑 git 命令。
$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']
工作区状态被格式化为 <workspace> 块,在每次迭代时自动注入系统提示词,让模型知道当前工作目录、分支、已修改文件等上下文。
注意:setWorkdir() 也会自动创建 WorkspaceManager(如果未显式设置),因此已有的 setWorkdir() 调用会自动获得工作区状态跟踪能力。
技能系统(Skills)
技能(Skill)是某项能力或工作流程的详细指令,按目录组织。默认只把「名称 + 描述」注入系统提示词(节省 Context),模型需要时通过 use_skill 工具加载完整正文。
$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:
---
name: deploy
description: 部署项目到生产环境
allowed-tools:
- Bash(git *)
- Bash(docker *)
---
# 部署流程
1. 执行构建
2. 上传到服务器
3. 重启服务
use_skill 工具被自动注册到 Agent 工具注册表,模型调用后加载完整技能正文,同时收集 allowed-tools 中的工具限制(不能突破全局权限)。
技能附件(references/ 等附带文件)
一个技能目录里除 SKILL.md 之外的文件都是它的附带文件:
skills/brand-archetype-system/
├── SKILL.md ← 正文(use_skill 返回)
└── references/
├── core-archetypes/06-luxe-considered.md
└── by-vertical/05-fintech-consumer.md
这不是可有可无的补充:官方 Agent Skills 的渐进披露是两层的,正文往往只写「遇到 X 就去
references/y.md 挑匹配的那一份」,真正的方法论在被引用的文件里。只给正文等于让模型猜文件名。
所以 use_skill 返回的不只是正文,后面还会附一段附件说明:
$handler = $sm->getUseSkillHandler();
echo $handler(['skill' => 'brand-archetype-system']);
(技能正文……)
<skill-resources>
技能目录:/path/to/skills/brand-archetype-system
本技能附带 33 个文件(相对上述目录;正文里引用的相对路径就在其中,需要时用 read_resource 读,skill="brand-archetype-system"):
- README.md
- references/01-how-to-apply-an-archetype.md
- references/core-archetypes/06-luxe-considered.md
…
</skill-resources>
模型随后用 read_resource 工具读具体附件(与 use_skill 一起由 Agent 运行时自动注册):
// 程序里直接读
$sm->readResource('brand-archetype-system', 'references/core-archetypes/06-luxe-considered.md');
- 边界由技能自己定义:附件路径只允许在技能目录内,
..穿越、绝对路径、 隐藏项(.git)、软链接逃逸、二进制文件、超限大文件一律拒绝并给出中文原因。 因此宿主不必把整个技能库目录加进read_file的白名单。 - 清单与读取用同一份体积上限(默认 1MB,
setResourceMaxBytes()):不会出现 「清单里列了、read_resource却读不了」。 - 清单会跳过
.git之类的隐藏项、根目录的SKILL.md,以及超大文件;默认最多列 200 条(setResourceListLimit()),超出只报个数。 - 不想让清单进上下文时
setResourceListVisible(false),行为退回「只回正文」。 SkillDefinition::getDir()/getResources()可单独使用(懒扫描 + 缓存), 宿主自己做技能索引时直接读这两个值即可。
按需发现与场景匹配(Skill 2.0)
技能多起来之后,loadFromDir() 会把每份正文都读进内存。discover() 只解析 frontmatter 登记技能,正文等模型真正 use_skill 时再读盘:
$found = $agent->discoverSkills('/path/to/skills'); // ['wordpress', 'deploy', 'nginx']
frontmatter 多认两个字段:
---
name: wordpress
description: WordPress 插件开发
files:
- wp-content
- "*.wp.php"
knowledge: |
WordPress 通过 hooks(action / filter)扩展。
插件入口在 wp-content/plugins/。
---
# WordPress 插件开发
(完整正文,模型 use_skill 后才加载)
knowledge是几行要点,随技能描述一起注入系统提示词(<skill-knowledge>块),不是完整正文files是触发该技能的文件通配符,用来按场景自动匹配
// 按文件路径找匹配的技能
$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 的技能永远匹配不到——按技能名去猜文件路径太容易误伤,宁可要求显式声明。
技能名很多时:去掉 schema 里的 enum
use_skill 的 schema 默认带技能名 enum(IDE 与模型都能少猜)。技能上百时,
一百多个名字每次工具调用都要重发一遍,可以关掉,把「先检索再加载」的约定留给 description:
$sm->setEnumInSchema(false);
子 Agent 的技能子集
宿主用子类扩展过 SkillManager(自建索引、按需发现等)时,取子集要用 subset()——
它会 clone 出同类型的新实例并保留定义的全部字段(含附件目录),按名 new SkillManager()
重建会把子类行为降级回基类:
$child = $sm->subset(['php-development', 'seo']); // 不继承激活状态
项目指令(InstructionManager)
加载项目级指令文件(CLAUDE.md / AGENTS.md),这些是项目必须遵守的长期规则,与 Skill 不同:
- CLAUDE.md / AGENTS.md = 项目必须遵守的长期规则
- Skill = 某项能力 / 工作流程
- Tool = 实际执行动作
$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。指令内容被格式化为 <instructions> 块注入系统提示词。
// 自定义文件名
$im->setFilenames(['CLAUDE.md', 'AGENTS.md', '.ai/AGENTS.md']);
MCP Runtime(McpManager)
MCP(Model Context Protocol)服务器管理,通过 stdio 子进程运行 MCP 服务器,将 MCP 工具自动注册到 Agent 工具注册表。
$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},避免不同服务器间的工具名冲突。
// 批量配置
$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 | 长连接双向通信 | 需要服务端推送、或一次会话里连续调很多次工具 |
$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']);
连接管理——不再是「一次性全起、全关」:
$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 五个作用域,各有独立的记忆文件。注入系统提示词时按作用域顺序合并,让模型感知不同层级的记忆上下文。
$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,支持追加、覆盖、清空、读取:
$mm->read('user'); // 读取
$mm->write('user', '仅此一条'); // 覆盖
$mm->forget('user'); // 清空
$mm->clearAll(); // 清空全部作用域
$mm->forPrompt(); // 生成注入系统提示词的 <memory> 块
记忆检索(MemoryRetriever)
forPrompt() 会把所有作用域的记忆整段注入。记忆攒多了之后这就成了负担:几千字的历史里
可能只有两行和当前任务有关,其余全在挤占上下文。检索器把记忆按行拆成条目,
只注入与当前任务最相关的几条:
$retriever = $mm->retriever();
$hits = $retriever->retrieve('登录接口报 401');
// [['scope' => 'project', 'line' => 3, 'text' => '登录走 JWT,密钥在 config/jwt.php', 'score' => 62.5], ...]
echo $retriever->forPrompt('登录接口报 401');
// <memory-relevant query="登录接口报 401">
// [project] 登录走 JWT,密钥在 config/jwt.php
// </memory-relevant>
$mm->retrieve('登录 401'); // 等价快捷方法
$mm->forPromptRelevant('登录 401'); // 等价快捷方法
$retriever->search('JWT', 'project'); // 纯关键词搜索,不打分
设置了 setGoal() 的 Agent 会自动走检索注入;没设目标时退回注入全部记忆,行为与升级前一致。
相关性是纯本地计算,不调模型:英文按词、中文按二元组切分,命中越多、覆盖查询的比例越高分越高。 这套打分认字面不认语义——问「鉴权」匹配不到只写了「登录」的记忆。需要语义检索时换掉打分器:
$retriever->setScorer(function ($query, $text) use ($ai) {
return cosineSimilarity($ai->embed($query), $ai->embed($text)) * 100;
});
$retriever->setTopK(3)->setMinScore(20.0);
压缩与过期——长跑任务里记忆会无限增长,定期清理比等到超出 maxInject 被截断更可控:
$retriever->compress('session', 20); // 只保留最近 20 条,返回删除条数
$retriever->expire('agent', 30); // 删除 30 天前的条目
expire() 只处理带日期前缀的条目([2026-09-02] ...),没有日期的一律保留——
分不清写入时间就不该替用户决定它过期了。
模型自主记忆(remember / forget 工具)
自 v1.65 起,remember() 默认给每条记忆加上日期 + 内容散列 id 前缀:
- [2026-09-03] (#9bda3a) 项目使用 CodeIgniter 3
id 取内容散列(非随机/自增),因此同一条内容重复 remember() 天然幂等,不会把
记忆文件撑满。remember($scope, $content, ['date' => false]) 可关掉日期前缀(id 仍写)。
配套的删除按 id 精确进行:
$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)按目标相关性——
即便某次检索零命中,长期记忆也不会消失。
自动沉淀默认关闭——自动从每次结果里提炼长期记忆比自动保存会话风险高得多, 必须显式开启:
$agent->autoConsolidate(true); // 每次 run()/chat() 后从结果提炼候选、去重后写入
检查点(CheckpointManager)
每轮迭代结束时自动保存检查点(Checkpoint),Runtime 崩溃后可从最新检查点恢复。每个检查点以 JSON 文件持久化,按任务 ID 分组。
$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 个检查点,超出的旧检查点自动清理。支持过期清理:
// 自定义保留数
$cm = new CheckpointManager('/tmp/checkpoints', [
'maxCheckpoints' => 10,
]);
// 清理超过 7 天的检查点
$cm->cleanExpired('task_1', 7);
崩溃恢复(Crash Recovery)
从崩溃中恢复——加载最新检查点,恢复消息上下文,继续执行。
$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。异常时也会自动保存崩溃时的检查点,防止数据丢失。
长任务恢复:不只是消息历史
小时级甚至天级的任务恢复回来,光有消息历史不够——得知道计划走到第几步、目标是什么,否则模型只能从消息里重新推断。检查点因此会一并保存运行时状态:
$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 下需要后台执行的场景。
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 先把目标拆成有序步骤再动手,而不是「想一步做一步」。计划带状态、依赖与修订历史,可持久化到磁盘,崩溃后继续执行。
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 只执行依赖已满足的步骤:
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 给出问题与建议,并可直接改计划:
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 上之后,计划摘要会在每轮迭代注入系统提示词(<plan> 块),
模型据此知道整体走到第几步:
$agent->setPlanDir('/var/data/plans'); // 可选,不设则只放内存
$plan = $agent->plan('重构支付模块', [
'读懂现有 Pay.php',
'拆出 PaymentGateway 接口',
'跑测试确认行为不变',
]);
$agent->run([['role' => 'user', 'content' => '开始重构']]);
echo $agent->getPlan()->progress(); // 执行到哪一步了
plan() 同时把目标写进 setGoal()——反思和记忆检索都用这个目标。
计划可以随任务状态一起落盘,崩溃恢复后接着执行:
$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 在每轮工具结果回填后判断「目标是否真的完成」,未完成则给出下一步行动建议,形成「执行 → 检查 → 未完成 → 继续」的闭环。
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 的文本
}
默认判据按顺序看四条:
- 最后一条 assistant 消息带完成标记(「任务完成」「已解决」「all done」…)→ 完成
- 最后一批工具结果里还有报错 → 继续修;同一个报错连撞两批则停手,继续重试没有进展
- 一个工具都没调过、且还在首轮 → 继续(模型还停在对话阶段)
- 其余 → 完成
第 2 条只看最后一批:早先的报错可能已经在后续轮次里修掉了,拿旧报错反复逼模型会一直空转到轮数上限。报错的判定以工具结果的 is_error 标志为准(库回填时一定带),只有调用方手工拼的、没有这个标志的消息才退回关键词扫描——对库产出的结果做关键词扫描会误伤,读一个正文里出现 error 的文件就算报错了。
第 4 条是「模型不再调用工具」这个信号本身:它比任何关键词都可靠。不要按轮次硬逼——回填的那句话不带任何任务信息,模型只会困惑地反问,而那句反问就成了调用方拿到的最终答案,真正的结论被顶掉。
需要模型驱动的反思时注入自定义策略:
$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 消息回填,驱动模型继续干,而不是就此结束。
$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 三样东西即可:
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,
// ]
链式配置:
$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 又让模型更难选对。
$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,模型需要别的能力时自己搜:
$agent->useToolDiscovery(['read_file', 'grep', 'glob']);
// 模型调用:search_tools(query: "database")
// → 找到 sql_query / db_schema,自动启用,之后可直接调用
搜索是纯本地的关键词匹配(工具名 + 描述),不调模型——为了省 token 而多花一次模型调用不划算。
收窄的是「给模型看什么」,不是「能执行什么」:工具注册表始终保有全量工具(否则搜出来激活了也执行不了),每轮真正发给模型的工具定义由激活集过滤。所以模型这一轮 search_tools 激活的工具,下一轮就能看见并直接调用。
分层权限策略
权限来自四个层面,关系是取交集而不是取并集,并且 DENY 优先:
最终权限 = Global AND Agent AND Skill AND Task
$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——重跑会产生新的副作用(再改一遍文件、再跑一遍命令),而客户端要的只是补上漏掉的那几条。
$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 找不到时返回全部——宁可重发也不要漏发,客户端自己去重比丢消息强。不传目录则只在内存里;断线重连场景必须配目录,重连时进程可能已经换了。
调度与并发上限
$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 用最便宜的模型去改架构是省小钱花大钱:
$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() 登记的表里按模型名自动匹配:
$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() 补进去(传数组是固定值,传闭包则每次路由前取实时值):
$agent->getRuntime()->getSubAgentManager()->setRouteContext(function ($def, $task) use ($budget) {
return ['budget_left' => $budget->remainingRatio()];
});
产物(测试报告、补丁、日志、分析结果)不该全文塞进上下文——一份 5000 行的报告扔进去,剩下的对话就没地方了:
$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() 把这两件事做成工具,交给模型在循环里自己决定:
$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 基础类,就直接把计划改掉,而不是照着开工前定死的那张表继续走:
$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() 就不会被覆盖:
$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、并行铺开,还是丢后台。
$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() 把这一步也交给模型:
$agent->useModelStrategy(); // 用当前模型决策
$agent->useModelStrategy(['model' => 'gpt-4o-mini']); // 决策用便宜的小模型
这是一次小而便宜的模型调用:只要一段 JSON,不给工具、不进 Agent 循环,同一个任务描述会缓存。模型拿不准、返回的不是 JSON、或者调用失败时自动退回规则版——决策失败不该是任务失败。模型点名一个不存在的子 Agent 时降级成 direct 自己干,而不是硬派出去。
决策一定会进事件流(strategy_decision 事件,带 reason)。Agent 自主选策略之后,使用者必须能回答「它为什么这么干」,否则出了问题只能靠猜:
$agent->onEvent(function ($e) {
if ($e['type'] === 'strategy_decision') {
echo "{$e['strategy']}:{$e['reason']}\n";
}
});
决策与执行是分开的,可以先看再决定要不要照做:
$decision = $agent->orchestrator()->decide('重构认证系统');
if ($decision->is(\Ai\Agent\Orchestrator\ExecutionStrategy::PLAN)) {
$result = $agent->orchestrator()->execute('重构认证系统', $decision);
}
默认选择器是基于规则的,不额外调模型——多花一次模型调用去决定「要不要多花模型调用」不划算,而且规则版的决策可复现、出问题能查。拿不准时一律退回 direct:保守执行的代价是多跑几轮工具,错误委派的代价是一个子 Agent 带着错误上下文跑十几轮。
需要模型驱动时换掉它:
$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 |
use Ai\Agent\SubAgent\BuiltinAgents;
BuiltinAgents::registerAll($sam); // 六个全装
BuiltinAgents::register($sam, ['explorer', 'tester']); // 只装两个
BuiltinAgents::isReadOnly('explorer'); // true
子 Agent 完整配置
$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 的子集。 这条是硬约束,不是建议:
$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 里:
$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() 按平台登记一次,库按模型名推断平台后自动配对:
$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。
三条规则,都是为了避免「静默打错平台」:
- 写了任何一个连接键,父 Agent 的连接信息就整份不再继承。 半继承最危险——留下父 Agent 的
base_url,Kimi 的模型名会被发到 DeepSeek 的地址上,报回来的是「模型不存在」,没人会往连接信息上想。 - 一个连接键都不写时行为与旧版一致:完全沿用父 Agent 的连接,只换模型名。OpenRouter / 自建网关那种「一把 Key 打所有模型」的用法不受影响。
temperature、max_tokens这类生成参数照常从父 Agent 继承,它们与「打到哪个平台」无关。
子 Agent 拿到的是独立 AI 实例(父实例 clone 而来,注入的 transport 与流式回调跟着走),所以:跑完父 Agent 的模型与 Key 原样不动,并行铺开时几个角色也不会互相踩。凭据不会进 transcript——SubAgentDefinition::toArray() 只列连接的键名,不含值。
完整可运行示例见 examples_multi_platform.php。
任务依赖图(TaskGraph)
父子关系(parentTaskId)表达的是从属,依赖表达的是顺序——两个兄弟任务可以同属一个父任务,却一个必须等另一个跑完。这两件事必须分开表达,否则「B 与 C 并行、D 等 C」这种结构根本写不出来。
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:让一个注定跑不起来的任务留在队列里等,只会浪费一次调度。
$graph->markFailed('x');
$graph->getStatus('y'); // 'blocked'(y 硬依赖 x)
$graph->blocked(); // ['y', 'z'] —— 传递阻塞
软依赖只约束顺序,上游失败也照跑:
$graph->dependsOn('q', 'p', \Ai\Agent\Task\TaskDependency::TYPE_SOFT);
会成环的依赖直接被拒绝(dependsOn() 返回 false)——环意味着谁都跑不起来,建图时报错比运行时死锁好查得多。自依赖同理。
与 TaskManager 打通:
$graph->syncFrom($taskManager); // 同步任务与状态,图结构不变
后台与并行执行
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,状态机一致 |
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):
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 里按需查——否则并行省下的时间,全赔在被污染的上下文里了。
$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]
默认是规则聚合(截断 + 归类 + 去重),不调模型。需要更好的摘要时注入模型摘要器——但那要多花一次调用,默认不替使用者做这个决定:
$aggregator->setSummarizer(function (array $results) use ($ai) {
return $ai->ask('把以下多路调查结果合并成一段摘要:' . json_encode($results));
});
摘要器抛异常时会退回规则拼接,不会因为摘要挂了把结果整个丢掉。
子 Agent transcript 落盘与续跑
$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 只在内存里,进程结束即丢——后台任务与崩溃恢复都需要它能被另一个进程读到,所以长任务场景必须配。
验证闸门与完成判据
不能因为模型说「完成了」就算完成。 模型判断自己是否达成目标是出了名的乐观:测试还红着、计划还剩三步、上一次工具调用还在报错,它照样会说「已完成」。
$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 | 语法 + 安全 | 认不出来时用它 |
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。也可以注册自定义判据:
$criteria->addCriterion('has_changelog', function (array $context) {
return ['met' => !empty($context['changelog']), 'reason' => '还没写 changelog'];
});
预设:CompletionCriteria::lenient()(只看有没有报错,适合没测试没计划的轻量任务)、::strict()(四条全要)。
任务交接(AgentHandoff)
Coder 改到一半发现是数据库结构的问题,把任务转给 DBA;后者处理完再转回来。交接必须留痕——否则一个任务在几个角色之间转了几圈之后,没人说得清它经历过什么。
$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——一个角色同时问了三件事时,没有这个字段就分不清答复对应哪个问题:
$req = AgentMessage::request('coder', 'dba', '这张表有索引吗');
$resp = AgentMessage::respondTo($req, '没有,需要补');
$resp->getReplyTo(); // $req 的 ID
跨 Session 消息(SessionBus)
AgentCommunication 管的是一个团队内部、同一个进程里的消息。跨 Session 是另一回事:后台 Agent 在另一个进程(甚至另一次 PHP 请求)里跑完,得让主 Session 知道。那条消息必须落盘才传得过去——内存里的队列,另一个进程根本看不见。
// 后台 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'); // <session-messages> 块,可直接注入
$bus->receive('session_main'); // 收完即清空
订阅回调只在本进程内 send() 时触发——跨进程投递的消息,另一端得靠 receive() 主动取。PHP 没有常驻的进程间推送通道,这一点无法回避。
不传目录时退化成纯内存模式,只在同进程内可用;后台任务通知必须配目录,否则消息发出去没人收得到。
计划版本链
modifyPlan() 不再原地覆盖,而是产生新版本。「原计划是什么、为什么改成现在这样」是排查 Agent 走偏的关键线索,直接覆盖就查不到了。
$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」。模型漏报和多报都很常见,尤其在它中途改了主意又改回去的时候。
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 拿回来了,接下来要么合入要么丢弃:
$samW->mergeWorktreeRun($runId, true); // 先试打(git apply --check)
$samW->mergeWorktreeRun($runId); // 真打
// ['applied' => true, 'reason' => 'applied']
$samW->discardWorktreeRun($runId, '方案不对'); // 留痕:这份 diff 被看过并否决了
用 git apply 打补丁而不是 merge 分支——worktree 里的改动通常没提交,没有 commit 可合。
Skill 生命周期与依赖
$sm->onEvent(function ($e) { echo $e['type'], "\n"; });
// skill_discovered / skill_loaded / skill_activated / skill_deactivated
$sm->deactivate('deploy'); // 停用:后续轮次不再注入,allowed-tools 一并重算
SKILL.md 多认两个字段:
---
name: deploy
required-tools:
- bash
- ssh
dependencies:
- docker
---
$sm->checkRequirements('deploy', ['bash']);
// ['satisfied' => false, 'missing' => ['ssh', 'skill:docker']]
required_tools 里的工具当前拿不到时,这个技能加载了也用不了——与其让模型按技能指示去调一个不存在的工具,不如提前说清楚。
指令就近发现
$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)
不要让所有工具结果自动进记忆。 那样记忆很快会变成一堆噪音:读过的每个文件、跑过的每条命令都在里面,真正重要的两三条反而被淹没。
Events → Task Result → Reflection → Memory Candidate → Consolidation → Memory
$consolidator = $agent->memoryConsolidator();
$consolidator->propose('project', '登录走 JWT,密钥在 config/jwt.php', ['confidence' => 0.9]);
$consolidator->proposeFromReflection($reflectionResult); // 反思结论值得记
$consolidator->proposeFromResult($agentResult, 'task'); // 只取首段,不整段塞
$written = $consolidator->consolidate(); // 去重 + 滤低置信度 + 按置信度排序 + 截断
候选不会立刻写盘——consolidate() 之前它们只在内存里排队。这一步存在的意义就是「攒一批再筛」:单条判断谁重要很难,一批放一起比较就容易多了。
去重用检索器的相关性打分做近似判断,说的是同一件事就不重复写。敏感内容用筛选器挡掉:
$consolidator->setFilter(function (array $candidate) {
return strpos($candidate['content'], '密码') === false;
});
多角色团队(AgentTeam)
从「父 Agent 派生子 Agent」升级为「一组各司其职的角色协作」。区别在于成员是长期存在的:Developer 改完代码,Tester 拿到的是同一轮任务的上下文,Reviewer 能看到前两者的结论。
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 的提示词明确「发现问题时给出复现步骤,不要自己去改实现代码」,否则多角色协作很容易退化成每个角色都在改代码。
自定义角色,并按角色收窄工具:
$team->addMember(new AgentRole('dba', [
'description' => '数据库结构与查询优化',
'prompt' => '你是 DBA,只负责表结构与索引,不改业务代码。',
'tools' => ['read_file', 'bash'], // 只给这两个工具
'maxIter' => 8,
]));
成员共享团队统一的工具集与权限配置,但各自持有独立的 AgentRuntime 与上下文——这正是多角色的意义:Tester 的上下文里不该塞满 Developer 的思考过程。
某个成员抛异常时记为 status = failed 继续往下走,不会中断整条流水线——Reviewer 知道 Tester 挂了,比什么都不知道有用。
Agent 间通信(AgentCommunication)
成员之间的消息在总线上投递与留存。纯文本传递会丢类型与来源,收到的一方分不清这是任务分派还是结果汇报,type 字段就是为此存在的。
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),等人批准才继续,驳回则带着理由退回去改。企业环境里这是硬要求——没有人签字的自动改动进不了生产。
$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 崩了重启也能接着等。不传目录则只放内存,适合同进程内的交互式确认。
其它能力:
$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 拿不到的这里都拿得到。
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:
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 后截图路径受工作区限制:
$agent->addTool(new BrowserTool([], new \Ai\Agent\Tools\PathSafety('/var/www/project')));
浏览器授权工具(BrowserGrantTool)
BrowserTool 拉起的是服务端自己的浏览器;这个取的是用户那台浏览器的驱动权——扩展装在里面、登录态是他的、窗口就在他眼前。凭据不能凭空要,得让用户亲自点一次确认,所以走「匿名票据」流程:
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/<hash>/,否则退到 projects/<slug>/),目录 0700、文件 0600;密钥明文只在取回那一次交给模型,本地留档是给后续步骤用的。身份不明(storageDir 为空,既没 userId 也没 sessionId)时工具拒绝写盘并说明原因——两个身份共用一份能驱动浏览器的密钥,比没有凭据更糟:
$agent->setUserId('admin:7')->setSessionId('sess-1'); // 至少给一个,才有地方安全地放凭据
wait_seconds 有上限(默认 120,最多 300),且会响应取消——用户点停止时不会继续在服务端干等。
浏览器操作工具(MciBrowserTool)
上面那个只把凭据取回来,之后怎么驱动浏览器还得自己接;MciBrowserTool 把「授权」与「操作」收进同一个工具名,模型只记一件事:
use Ai\Agent\Tools\MciBrowserTool;
$agent->addTool(new MciBrowserTool('https://example.com', ['app' => 'MCI AI Agent']));
// 调用方若知道「用户此刻正在用的那台」,经 client_device 传进来更准(可选):
// 页面从扩展写在 <html data-mci-device> 上的设备号读来,随消息一起交过来。
// $agent->addTool(new MciBrowserTool('https://example.com', [
// 'app' => 'MCI AI Agent',
// 'client_device' => 'd5708b07f',
// ]));
// 授权那一半(没凭据时先做;与 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默认取「用户此刻正在用的那台」:调用方把页面从扩展写的<html data-mci-device>读到的设备号经构造选项client_device传进来,工具拿它跟站点的在线清单(/Browser/Api/status)核对过才用 —— 用户要的往往是他面前这台,而不是几天前授权时随手选的那台。它只是个提示:那台不在线、或清单查不到(网络 / 权限),就退回凭据里授权的那台,并把「这条命令发给了哪台」写进结果 —— 否则用户看着自己这台没动静,会以为是「点了没反应」。同一实例一轮里只核对一次;不传client_device时行为与从前完全一致,确实要换台仍显式传device。open默认active=0:AI 干活多在后台,抢走焦点等于打断用户手头的事。- 自己开的标签要自己关:
open的结果里直接附上带 tab id 的关闭命令,工具描述也把「收尾用close」写成硬要求;不传tab时关的是你上次操作的那个标签,用户的其它标签不受影响。少了这条,AI 每干一次活就在用户浏览器里留下一排标签。 - 截图走
inline=1取字节交给媒体门面,图直接进上下文,模型是真看得见页面;站点上那份落盘路径也一并回报。调用方没挂媒体门面时,结果里会明说这次看不到图,而不是让模型以为自己看过了。 - 参数按动作走白名单:模型多给的键(通用目标
device除外)不会原样转给站点。少了这层,工具就成了「把模型输入拼进请求」的通道。 - 限流、配额、来源白名单、审计都还在站点那侧(
/Browser/Api/*),漏过这一层不会绕过任何一道。
代码理解(CodeAnalyzer)
Agent 改代码前得先看懂代码。Ai\Code 扫描项目建立类索引与两张关系图——谁调用了谁、谁依赖了谁——之后回答「改这个方法会影响谁」不用再 grep 整个项目。
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() 生成一段可直接注入提示词的类说明,比把整个类的源码塞进上下文省得多,而且带上了单看源码看不到的「谁依赖我」:
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
单文件分析——不建索引也能用:
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 也能单独用:
$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 全项目,也不必把整个类的源码读进上下文。
$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 直接读索引,不用每次都把项目结构重新摸一遍。
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(); // 注入提示词的 <project> 块
框架识别先看 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 主动向用户提问)
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、上下文压缩、会话启停。
$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 的快捷方法里,需要直接注入钩子容器:
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 |
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() 判断方法。
执行顺序
钩子按固定顺序接入执行链:
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、sessionIdTaskState— 任务状态记录,包含 completed、pending、blocked、importantFacts、modifiedFiles、errors、subtasksTaskManager— 任务管理器,管理任务生命周期(queued → running → waiting_permission/waiting_user → paused → completed/failed/cancelled)TaskStatus— 状态枚举常量
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 在上下文压缩后仍知道任务状态:
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
$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 / read_resource 工具
├── 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——不重写业务逻辑,不新建一套权限系统。
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 入口。
三行接入
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 方法上加几行注释即可,业务代码一行不用改:
/**
* 修改文章
*
* @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 和静态分析当场发现:
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:
{
"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<int, string>、字面量联合 'draft'|'published'(自动变成 enum)。
索引与 CLI
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:
// .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 请求时才做全量扫描:
Git Push → CI/CD → composer install → php-ai index → 应用启动
CI 里可以用 php-ai index --check 校验索引是否跟代码同步(过期返回退出码 1)。
增量扫描
每个源文件的内容 hash 存在 Registry 里,hash 没变就跳过,一万个文件里改了 12 个就只重新解析这 12 个。文件被删除、或标注被拿掉时,其名下的 Tool 会被自动清除。
扫描器还做了两层保护:
- 文件内容里既没有
@agent-tool也没有AgentTool时,连 include 都不做——业务代码里的顶层副作用(连数据库、注册路由)不会因为一次索引被跑起来; - 类名靠
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 入口 + 应用现有权限校验) |
一次典型交互:
用户:把昨天销量最高的商品价格降低 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 串——汉字切成单字 + 相邻二元组,英文按分隔符切开并小写:
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 | 需要确认,且不能被阈值绕过 |
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 会做三件事:
- 未声明的参数一律丢弃,不透传给 Controller。让模型能往 Controller 塞任意字段,等于把入参白名单交给模型决定;
- 必填缺失直接失败,不用默认值蒙混过去;
- 只做无歧义的类型收敛——
"123"→123、"true"→true这类 JSON 传输损耗要修,但"abc"→0这种会把「模型填错了」变成「Controller 收到 0」的转换一律拒绝。
枚举取值不在允许范围时同样拒绝执行。
三条安全红线
-
Discovery 的权限过滤只是优化,不是安全边界。
ToolSearchContext的permissions与网关的can()都跑在「模型还没决定用哪个 Tool」的时候,结果可能被缓存、可能过期,也可能因为对象级规则(「只能改自己写的文章」)而不完整。真正的授权只有一个地方:ControllerGatewayInterface::dispatch()里应用自己的那套校验。dispatch()里不做权限校验,是这套方案唯一的致命误用。 -
没有
@agent-controller的 Tool 拒绝执行。 没有 Controller 入口就没有权限边界。这种 Tool 仍会被索引(能被搜到,索引时会给出警告),但执行阶段直接拒绝——绝不退回「拿 class/method 反射直调」那条路。 -
Agent 不碰业务数据库。
registry.sqlite只存 Tool 元数据。业务数据永远走 Controller / Service,因为改一篇文章通常不只是一条 UPDATE:还有权限检查、缓存清理、搜索索引更新、修改日志、Webhook、审核状态、CDN 清理。这些都该由原有 Service 负责。
审计
每次执行(成功 / 失败 / 被拒 / 待确认)都会回调一次:
$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 改代码」的完整协议:把编辑器现场(当前文件、光标、选区、已打开文件、工作区规范)结构化后交给模型,模型返回可校验、可执行的编辑动作。
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 配置:
$ai = AI::create([
'model' => 'qwen-plus',
'api_key' => 'sk-xxx',
'search' => true, // 开启联网搜索
]);
echo $ai->chat('今天有哪些重要新闻?')->getContent();
换个平台只改 model,search 这行不动:
$ai->setConfig(['model' => 'claude-sonnet-4-20250514', 'api_key' => 'sk-ant-xxx']);
$ai->setConfig(['model' => 'glm-4-plus', 'api_key' => 'xxx']);
细化配置
search 传数组可以调细节。省略 enable 即视为开启:
$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()只会拿到一个工具调用:$ai->setConfig(['model' => 'kimi-k2-0905-preview', 'search' => true]); $agent = new \Ai\Agent\Agent($ai); // ✅ 用 Agent $agent->run([['role' => 'user', 'content' => '今天有哪些重要新闻?']]); echo $agent->lastText();
哪些平台支持
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 直接写:
$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……)。本库把它们归一成两份列表,
不用关心平台差异,按需取用:
$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;不走站点代理
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 工具即可让模型自主上网:
'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 读到的图片,都能被模型真的看到。主模型不支持视觉时,自动交给已配置的视觉模型处理,用户无感。
use Ai\Agent\Media\Attachment;
$agent->chat('这张截图里报的是什么错?', [
Attachment::fromPath($_FILES['shot']['tmp_name']),
]);
三条设计约定,都是踩过坑之后定下来的:
- 对话历史里存引用,不存 base64。 一张 5 MB 的图 base64 后约 6.67 MB,写进会话 JSONL 之后每次 load/save 都要搬运这几 MB。
- 模型看不了图时明确告知,绝不伪装。 不用
[图片: x.png]这种占位符——它看起来像「图已附上」,会诱导模型写出「从图片可以看出……」。 - 附件真的进对话历史。 第 3 轮再问「刚才那张图里的数字是多少」,图还在;会话恢复之后也还在。
快速开始
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 交叉校验这关被拦下。
附件存哪儿
对话历史里只有引用:
{
"type": "agent_media",
"media": "image",
"mime": "image/png",
"name": "screenshot.png",
"bytes": 20480,
"ref": "media://6a9e37f47d0572c332fb73b2"
}
真实字节归 MediaStore 管,默认落在 AgentHome 的媒体目录(与 sessions 同级,沿用同一套项目隔离与 userId 隔离规则)。发请求的那一刻才由 MediaResolver 解析成 base64。
要接对象存储就自己实现 MediaStoreInterface:
$agent->media(['store' => new MyS3MediaStore($bucket)]);
媒体文件的清理责任在应用。 库不做自动 GC——它不知道同一个 ref 还被哪些会话引用,自动删是不可逆的。提供两个显式接口:
$agent->mediaManager()->prune(30); // 清理 30 天前的
$agent->mediaManager()->gcByMessages($liveMessages); // 按还活着的引用回收
限额与安全
一个总限制是不够的:5 MB 单文件很合理,但十个一起发就是必然失败的请求,而且失败发生在平台侧、错误信息通常很难懂。所以是三级:
$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 直接读图
$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 不接受连续两条同角色消息。
自动模态路由
主模型是纯文本时,配一个能看图的平台,剩下的库自己处理:
$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 原样保留。两个好处:以后换成视觉模型还能重新看原图;同一张图在后续轮次里不会被反复描述。
回填时注明来源,主模型收到的是:
[图片「shot.png」的内容描述 —— 由 gemini-2.5-flash 查看后生成,当前模型无法直接查看该文件]
图中是一个红色矩形按钮,上面用白色大写字母写着 SUBMIT,下方有黑色数字 7431。
不写来源的话,主模型会把二手描述当一手观察,在描述有偏差时给出过度自信的结论。
要用自己的方式构造视觉模型的连接(比如走内网网关):
$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 模型。
// 查一下
$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,既不说是哪个字段的问题也不说是不支持。
没有视觉模型时会怎样
不会静默忽略,也不会假装看到。模型收到的是一句事实陈述:
[系统提示] 用户上传了图片「shot.png」,但当前模型无法查看图片内容。
请不要臆测其内容;如果需要,请告诉用户换用支持该类型的模型。
实测下模型的回答是「我看不到这张图片。当前模型无法查看图片内容。」——没有编造。
API 速查
| 类 | 作用 |
|---|---|
Ai\Agent\Media\Attachment | 附件入口:fromPath / fromUrl / fromBase64 |
Ai\Agent\Media\MediaReference | media://<id> 值对象与 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)。文件位置由业务层决定,库不认识任何具体路径。
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,并做失败重试、格式校验与结果回写。
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> 会被转义成 <\/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 单独成批;
- 失败时把模型返回的原文一并记录下来,否则无从排查。
扩展开发
新增模型
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 多个平台协议都是这么实现的:
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)时:
- 实现
Ai\Contracts\ProtocolInterface:buildRequest、parseResponse、buildHeaders、parseStreamChunk、isStreamEnd、listModels; 可选实现defaultBaseUrl()/chatPath()/modelsPath()供自动组装端点,use ModelCatalog获得常用模型清单与兜底能力; - 创建该平台的模型类,
$protocol指向新协议;或直接把协议类名传给protocol配置项(无需改动本库):AI::create(['model'=>'x', 'protocol'=>'App\\Protocol\\MyApi', 'base_url'=>'https://api.my.com']); - 在
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() 配一次,各能力自动生效。
$ai = new AI(['api_key' => '...', 'model' => '...']);
$ai->embeddings(); // 文本向量化
$ai->images(); // 图像生成
$ai->audio(); // 语音合成 / 识别(HTTP)
$ai->video(); // 视频生成(异步任务)
$ai->realtime(); // WebSocket 通道,默认关闭
先判断再调用
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)
文本向量化(已可用)
$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 归位。这类错位不会报任何错,只会让后续检索莫名其妙地不准。
平台参数直接透传
$ai->embeddings()->create($texts, [
'dimensions' => 512, // OpenAI text-embedding-3-* 支持降维
'encoding_format' => 'base64', // 返回 base64 时库会自动解回 float 数组
]);
只归一 model / input 两个字段,其余原样透传给平台。
dimensions 这类参数各平台支持度不同,以对方文档为准——库不替你猜,
不支持的平台会直接返回它自己的错误信息。
超长批量自动分批
各平台单次可提交的条数上限不同(OpenAI 上千条,部分平台只有一二十条),
且官方文档未必写明。默认不分批(一次发完,不无谓拆分请求),
需要时用 batch_size 指定:
$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即可绕过库的判断。
图像生成(已可用)
$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 |
| 通义万相 | 异步任务式,见下节 |
$ai->images(); // 子门面
(new \Ai\Protocol\Zhipu())->knownImageModels(); // 取某协议的图像模型清单
通义(qwen)走异步接口:其 OpenAI 兼容模式没有同步文生图(实测 404),
万相是「提交任务再轮询」,用 generateAsync(),见下节。
某个平台是否真的开通了这个接口以对方文档为准——未开通时你会收到该平台自己的 404, 而不是库的猜测。库的判断有误或过时时,配
image_endpoint即可绕过。
异步文生图(通义万相)
通义万相的文生图是提交任务再轮询,一次请求拿不到图。这类平台上
generate() 会明确报错并指向 generateAsync()——返回一个「成功但没有图」的
响应是最坏的做法,调用方拿到空结果完全不知道去哪找原因。
$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"(星号)。 分隔符传错不会被容错,平台直接判为非法参数。
图像编辑(图生图 / 局部重绘)
// 整图改写
$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)。
语音合成与识别(已可用)
// 文本 → 音频
$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(),拿到的都是可以直接播放的文件。
这里有两个不会报错的坑,库内都堵上了:
- 平台出错时回的是 JSON,不是音频。 不判别就会写出一堆扩展名
.mp3、 内容是错误信息的文件,全程无报错。库内按响应的实际Content-Type判别, 拿到 JSON 一律当错误处理,saveTo()会直接抛异常而不是写出坏文件。 - MiniMax 的 hex 不是 base64。 两者都是可打印字符,用
base64_decode去解 不报错,只表现为「文件存下来了但放不出声」。另外它的错误不体现在 HTTP 状态码上——base_resp.status_code非 0 才是失败,此时 HTTP 仍是 200,库内会检查这个字段。
音色缺省
OpenAI 的 voice 是必填参数,不传直接 400。库内会补一个默认音色,
让 speech('你好') 开箱可用;你显式传了就以你的为准。
(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 对接。
$ai = AI::create([
'protocol' => 'spark',
'app_id' => '<控制台的 APPID>',
'api_key' => '<APIKey>:<APISecret>', // 冒号拼接
]);
// 必须显式启用 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() 返回的是任务而不是视频。
// 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 用的。
// 只在 CLI / worker 里这么用
$task->wait(300, 3); // 最多等 300 秒,起始间隔 3 秒后指数退避
超时不是失败
wait() 超时不抛异常。任务在平台侧仍然在跑,抛异常会诱导你 catch 之后当失败处理,
白白丢掉一次已经付费的生成。超时后:
$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() 及时取回:
$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 请求不同:
$ai->realtime()->useWebSocket()->speech('你好世界');
不启用直接调用会得到明确提示,而不是含糊的连接失败。
自定义网关
把 base_url 指向自建网关或中转服务时,图像/语音端点会自动跟着走同一个网关,
不会回落到官方地址(那意味着把数据发到你没指定的服务器上)。
需要单独指定某个能力的完整地址时,用 <能力名>_endpoint 配置项:
$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):加一行即可。
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:
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