progincc / ai-assistant
配置驱动的 PHP AI 助手:RAG 知识库 + Function Calling,框架无关,针对中文场景优化
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
None
Suggests
- ext-pdo: 如需把向量存进数据库(pgvector / MySQL)
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-23 11:59:25 UTC
README
配置驱动的 PHP AI 助手包:知识库问答(RAG)+ Function Calling + 多轮对话 + 流式输出, 框架无关,零第三方依赖(只用 curl),针对中文场景做了专门优化。
目标:让你在老项目里
composer require之后,只改配置、传文档,就能有一个不瞎编的 AI 客服。
一、它能干什么
| 能力 | 说明 |
|---|---|
| 知识库问答 | 丢文档进目录即可 → 自动切块 → 向量化 → 入库;改了自动增量重建 |
| 中文切块 | 按中文标点分句、自动带【文档 > 章节】前缀、相邻块重叠 |
| 混合检索 | 向量路 + 关键词路 → RRF 融合,订单号/规格/金额也能命中 |
| 重排 | 接百炼 qwen3-rerank 精排,接口挂了自动降级 |
| 闸门 | 最高分低于阈值直接判定「资料里没有」→ 转人工,杜绝瞎编 |
| Function Calling | 注册 PHP 函数当工具,让 AI 查你的数据库 |
| 多轮对话 | 历史存 session/Redis/DB,下轮带回来 |
| 流式输出 | SSE 逐字吐字(打字机效果) |
| 框架无关 | 核心不依赖任何框架,examples/ 里有 ThinkPHP / Laravel 接法 |
二、安装
composer require progincc/ai-assistant
要求:PHP >= 8.1,开启 curl / mbstring / json 扩展。
三、三分钟跑起来
你要配的东西,只有四项
| # | 配什么 | 配置项 | 一句话说明 |
|---|---|---|---|
| ① | 对话模型 | chat |
谁在回答用户(哪家、什么模型、Key) |
| ② | 向量模型 | embed |
把文档变成可检索的数字。厂商/模型/维度/Key 自己填 |
| ③ | 帮助文档 | docs |
答案从哪来 —— 把 .md/.txt 丢进一个目录就行 |
| ④ | 向量存储 | store |
索引存哪 —— 就一个文件 |
② 之所以单独列出来:DeepSeek 不做向量,必须另配一家,且厂商、模型名、维度、Key 四项要配套。
① 准备 Key
| 用途 | 推荐 | 说明 |
|---|---|---|
| 对话模型 | DeepSeek | deepseek-v4-flash(便宜) / deepseek-v4-pro(更准) |
| 向量模型 | 阿里云百炼 | text-embedding-v4,有免费额度 |
| 重排模型 | 阿里云百炼 | qwen3-rerank,不用配,跟着向量那家自动开/关 |
⚠️ DeepSeek 没有 embedding 接口,向量必须另找一家。 这是最常见的踩坑点:对话和向量是两个不同的 Key、不同的地址。
② 写配置(就这么长)
// config/ai.php return [ // ① 对话模型 'chat' => [ 'driver' => 'deepseek', // 内置厂商,不用查地址 'key' => getenv('DEEPSEEK_KEY'), // 建议走环境变量,别进 git 'model' => 'deepseek-v4-flash', ], // ② 向量模型(四项都自己填:厂商 / 模型 / 维度 / Key) 'embed' => [ 'driver' => 'bailian', 'key' => getenv('BAILIAN_KEY'), 'model' => 'text-embedding-v4', 'dimensions' => 1024, ], // ③ 帮助文档:把 .md / .txt 丢进这个目录 'docs' => __DIR__ . '/docs', // ④ 向量索引存哪 'store' => __DIR__ . '/storage', ];
③ 用(没有"建库"这一步)
$ai = AiAssistant::make(__DIR__ . '/config/ai.php'); echo $ai->ask('开箱了还能退吗')->text; // → 未拆封可以退货,食品类拆封后不支持退货。
改完配置不知道生效了没?跑一下体检(不发请求、不花钱):
php examples/config_check.php
它会打印「对话/向量实际用的哪家、哪个模型、多少维」、哪些项是你没填被补的默认值, 以及配错的地方(比如厂商填了 A 家模型却是 B 家的名字)。
不用手动建库。 第一次调用会自动把 docs 目录里的文档入库;
以后文档改了自动重建改动的那部分,删了自动移除,没动的一块都不重算(不花钱):
print_r($ai->syncStats()); // ['added' => 0, 'updated' => 1, 'removed' => 0, 'unchanged' => 3, 'chunks' => 15, 'rebuilt' => false]
想看回答依据、命中分数、检索过程:
$answer = $ai->ask('开箱了还能退吗'); echo $answer->text; // 回答 print_r($answer->sources()); // ['policy.md'] —— 可展示「本回答依据:《售后政策》」 print_r($answer->hits); // 命中了哪几条资料、分数多少 print_r($answer->debug); // 检索过程:vec_top / kw_top / gate / rerank
资料里没有的问题,它会直接说兜底话术,不会瞎编:
$ai->ask('你们老板电话多少'); // → 这个问题我需要为您转接人工客服。
三块配置各自的细节
① 对话模型 chat
'chat' => ['driver' => 'deepseek', 'key' => 'sk-xxx', 'model' => 'deepseek-v4-flash'],
内置厂商(写名字即可,不用查地址):
deepseek / bailian(阿里云百炼)/ siliconflow / zhipu / moonshot / openai / ollama。
不在列表里就加一行 'base_uri' => 'https://你的地址/v1'。
嫌长还能更短(会自动补默认值):
'chat' => 'sk-xxx', // 只给 Key → 默认 deepseek + deepseek-v4-flash 'key' => 'sk-xxx', // 顶层 Key:chat 用;embed 只在「整段没配」时才用它
Key 也可以完全不写进文件,设环境变量即可自动读取:
DEEPSEEK_KEY / DASHSCOPE_KEY(百炼)/ SILICONFLOW_KEY / OPENAI_API_KEY …
② 向量模型 embed(请用哪家请自己定)
'embed' => [ 'driver' => 'bailian', // 厂商 'key' => getenv('BAILIAN_KEY'), // 这家的 Key 'model' => 'text-embedding-v4', // 模型名 'dimensions' => 1024, // 输出维度,必须和模型对得上 ],
常用组合(driver / model / dimensions 必须配套,填错维度不会报错,只会让检索变笨):
| 厂商 | driver | model | dimensions |
|---|---|---|---|
| 阿里云百炼 | bailian |
text-embedding-v4 |
1024(有免费额度) |
| 硅基流动 | siliconflow |
BAAI/bge-m3 |
1024 |
| 智谱 | zhipu |
embedding-3 |
2048 |
| OpenAI | openai |
text-embedding-3-small |
1536 |
| 本地 ollama | ollama |
bge-m3 |
1024 |
默认值规则(重要):
- 你填了的项 → 一个字都不改,完全听你的
- 你没填的项 → 才补默认值,而且补出来的默认值跟你填的厂商配套
(填了
siliconflow就给BAAI/bge-m3,不会给你百炼的模型名) - 整个
embed段都不写 → 才回落到「百炼 + text-embedding-v4 + 1024」 - 猜不出来就直接报错,绝不硬塞:把
deepseek填进 embed 又不写 model → 告诉你"DeepSeek 不提供向量接口,请换一家";只给base_uri→ 让你自己填 model - ⚠️ 只要
embed段你写了任何一项,就不会拿 chat 的 Key 去充数 (DeepSeek 的 Key 打百炼必然 401,这种"好心"是帮倒忙)
想知道哪些项是系统替你补的:
print_r($ai->config()->usedDefaults()); // ['embed.driver(bailian)', 'embed.model(text-embedding-v4)', 'embed.dimensions(1024,按模型查表)']
换向量模型 / 改维度 → 索引会自动全量重建,不用担心新旧向量混用。
③ 帮助文档 docs
目录、单文件、文档数组、或者混着给都行:
'docs' => __DIR__ . '/docs', // 目录(扫 .md/.txt,含一层子目录) 'docs' => [__DIR__ . '/docs/a', __DIR__ . '/docs/b'], // 多个目录 'docs' => __DIR__ . '/docs/policy.md', // 单个文件 'docs' => [ // 从数据库读出来的文章 ['source' => '售后政策', 'content' => '退货时效:签收后3天内…'], ['source' => '配送说明', 'content' => '全国包邮,新疆西藏不发货。'], ], 'docs' => [__DIR__ . '/docs', ['source' => '公告', 'content' => '…']], // 目录 + 数据库,混着来
⚠️ 清单语义:每次同步传入的列表 = 「库里应该有的全部内容」。 目录文档和数据库文档要一次给全,不要分两次 sync —— 第二次会把第一次的内容删掉。
改了 knowledge.extensions 可以扫别的文件类型(默认 ['md','txt'])。
Markdown 顶部的 ---\ntitle: xxx\n--- 会被自动剥掉,不进向量库。
④ 向量存储 store
'store' => __DIR__ . '/storage', // 目录,会自动创建
里面就两个文件,加进 .gitignore 即可:
storage/knowledge.store 向量索引(JSONL)
storage/_sync.json 同步指纹(记录哪些文档已入库)
换向量模型、改切块大小 → 索引会自动全量重建。 因为新旧向量不在同一坐标系里,混用会导致检索全错但不报错,所以必须重建。
四、Function Calling:让 AI 查你的数据库
模型只决定「调哪个工具、传什么参数」,真正执行的是你自己的 PHP 代码, 模型从头到尾碰不到你的数据库。
use Progincc\AiAssistant\Tools\Tool; $ai->addTool(Tool::make( name: 'query_order', description: '根据订单号查询订单状态和物流。用户问订单到哪了时使用;没有订单号先问用户要。', parameters: [ 'type' => 'object', 'properties' => [ 'order_no' => ['type' => 'string', 'description' => '订单号,形如 SN20260921001'], ], 'required' => ['order_no'], ], handler: function (array $args) use ($uid): string { // ⚠️ 模型给的参数一律当「用户输入」处理:校验格式 + 校验归属 $row = Db::name('order') ->where('order_no', $args['order_no']) ->where('user_id', $uid) // 不加这行 = 任何人能查别人订单 ->find(); return json_encode($row, JSON_UNESCAPED_UNICODE); } )); echo $ai->ask('我的订单 SN20260921001 到哪了')->text;
工具最多连续调用 tools.max_rounds 轮(默认 5),超出后强制让模型给结论。
五、多轮对话
AI 没有记忆,多轮 = 把上一轮问答原样塞回 messages。历史得你自己存:
$ai->setHistory(session('ai_history') ?: []); $answer = $ai->ask($q); $history = $ai->history(); if (count($history) > 20) { // 只留最近 10 轮 $history = array_slice($history, -20); } session(['ai_history' => $history]);
六、流式输出(打字机)
$ai->stream('退货要几天', function (string $delta) { echo 'data: ' . json_encode(['delta' => $delta], JSON_UNESCAPED_UNICODE) . "\n\n"; ob_flush(); flush(); });
前端用 EventSource 接。响应头要加:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no ← 关掉 nginx 缓冲,否则前端一直收不到
⚠️ 流式与 Function Calling 不共存(半截参数没法执行工具)。 ⚠️ PHP-FPM 下每个 SSE 连接占住一个 worker,并发一高整站卡死。 正经做法:AI 服务独立部署(Webman / Swoole / Octane),或前端直连厂商流接口。
七、进阶配置(默认值不够用时才看)
上面 4 项跑起来之后,如果效果不满意,再来调这些。
全部可选项的完整清单见 config/ai.full.php,最常用的几个:
下面这张表是代码里实际会读的全部配置项(tests/audit.php 会校验它和代码一致,漏写会报错)。
| 配置项 | 默认 | 说明 |
|---|---|---|
| ① 对话模型 | ||
chat.driver |
deepseek |
厂商名,或填 chat.base_uri 用自建地址 |
chat.key |
— | 必填(或设 DEEPSEEK_KEY 等环境变量) |
chat.model |
deepseek-v4-flash |
模型名;不填按厂商给默认(百炼→qwen-plus) |
chat.options |
[] |
透传给模型的采样参数,如 ['temperature' => 0.3]。★ 客服场景别超 0.5,越高越容易瞎编 |
| ② 向量模型 | ||
embed.driver |
bailian |
建议自己填;不填才用默认 |
embed.key |
— | 必填(用知识库时)。⚠️ DeepSeek 不做向量,得另配一家 |
embed.model |
text-embedding-v4 |
建议自己填;不填按厂商给默认(见上文对照表) |
embed.dimensions |
按模型查表 | 不填按模型名查表;模型不在表里请自己填准 |
embed.batch_size |
10 | 向量化每批发几条(建库速度相关) |
| ③ 重排 | ||
rerank.enabled |
自动 | 跟着向量厂商自动开关;关掉省一次调用,准确率略降 |
rerank.key |
复用 embed.key |
留空即可;不同 Key 才需要填 |
rerank.model |
qwen3-rerank |
目前只有百炼系提供 |
| ④ 知识库 | ||
knowledge.enabled |
true | 不需要文档问答就设 false,可省掉向量模型 |
knowledge.docs |
— | ★ 文档来源:目录 / 文件 / 文档数组 / 混合。顶层写 docs 也行 |
knowledge.auto_sync |
true | 自动同步文档目录;生产环境可设 false 改手动 |
knowledge.extensions |
['md','txt'] |
扫哪些后缀(含一层子目录) |
knowledge.gate |
0.45 | ★ 最重要:低于此分判定「资料里没有」→ 转人工 |
knowledge.top_k |
3 | 最终喂给模型几条资料 |
knowledge.vec_min / kw_min |
0.35 / 0.25 | 单路及格线,低于它该路权重降到 0.25 |
knowledge.followup_with_history |
true | 追问补充:本问检索不到时带上一问重试(见下方说明) |
knowledge.chunk.max |
300 | 每块最多字数(太长检索不准,太短丢上下文) |
knowledge.chunk.overlap |
30 | 相邻块重叠字数,避免答案被切断 |
knowledge.chunk.min |
10 | 碎块丢弃阈值 |
| ⑤ 存储 | ||
knowledge.store_dir |
— | ★ 索引存放目录。顶层写 store 也行 |
knowledge.store_name |
knowledge |
索引文件名(一个库一个名字) |
knowledge.precision |
4 | 向量存几位小数(4 位够用,调大文件会变大) |
| ⑥ 提示词 | ||
prompt.role |
专业客服,语气礼貌简洁 | 人设,按你的业务改("你是XX商城的客服") |
prompt.fallback |
转人工话术 | ★ 模型「不会时该说什么」 |
prompt.max_chars |
80 | 回答字数上限 |
prompt.rules |
[] |
自定义规则;留空用默认四条,填了完全覆盖(谨慎) |
| ⑦ 工具 / HTTP | ||
tools.max_rounds |
5 | 工具调用最大轮次,防死循环 |
http.timeout |
60 | 请求超时(秒) |
http.connect_timeout |
10 | 连接超时(秒) |
http.retries |
1 | 失败重试次数 |
http.ca_file |
null | Windows 报 cURL 60 时指向 cacert.pem |
手动同步(关掉 auto_sync 之后)
$stat = $ai->knowledge()->sync($docs); // 目录 / 文件 / 文档数组都行 // ['added' => 2, 'updated' => 0, 'removed' => 1, 'unchanged' => 5, 'chunks' => 23, 'rebuilt' => false] $ai->knowledge()->clear(); // 清空整个库(换资料体系时用) $ai->knowledge()->count(); // 现在多少块 $ai->knowledge()->sources(); // 库里有哪些来源
追问(「那西藏呢」)是怎么处理的
这是多轮里唯一有技术含量的地方,也踩过两次坑,说清楚:
用户接着上一句问「那西藏呢」,这句话单独拿去检索,向量跟任何资料都不像, 闸门会判定「资料里没有」→ 直接转人工。不能接受。
走过的两条错路(代码里已修,别再走回去):
| 错路 | 后果(实测) |
|---|---|
| ① 无脑把上一问拼上去 | 用户切换话题时被带偏:「钱啥时候能退回来」+「新疆发货吗」混成四不像,配送范围那条被挤下去,新疆问题答不出来 |
| ② 两路都算分取高的 | 拼接后文本变长,余弦虚高(无关拼接实测从 0.38 抬到 0.66),必然误选噪声,答非所问 |
现在的策略是回退式:
- 先拿本轮提问单独检索,有结果就直接用(覆盖绝大多数情况)
- 只有在「没检索到」且「这句话像追问」(≤25 字 + 含 那/这/它/再/还/另外/然后…)时,才带上一问重试一次
每次实际走了哪条路,$answer->debug['query'] 里都写着(本问 / 追问改写 / 本问(不像追问,不做改写))。
gate 怎么定
不要拍脑袋。跑一次 debug,看你自己数据里「相关」和「不相关」的分值分布, 取两者中间:
AI_DEBUG=1 php examples/basic.php # 检索:{"vec_top":0.72,"kw_top":0.31,"gate":"passed",...} # 相关一般 0.7+,不相关 0.3 左右 → gate 取 0.45~0.5
八、目录结构
src/
├── AiAssistant.php 门面(唯一入口)
├── Assistant.php 编排:检索 → 提示词 → 模型 → 工具循环
├── Knowledge.php 入库:切块 → 向量化 → 落库(sync 做增量同步)
├── Config.php 配置容器 + 厂商地址预设 + 简写展开
├── HttpClient.php curl 封装(含 SSE 流式)
├── Answer.php 回答结果
├── Chunk/ 切块(ChineseSplitter 是重点)
├── Contracts/ 四个接口:Chat / Embed / Rerank / VectorStore
├── Drivers/ OpenAI 兼容对话、向量、百炼重排
├── Exceptions/ AiException(带上下文,报错时能看到 URL 和厂商原文)
├── Prompt/ 提示词契约(防瞎编的关键)
├── Retrieval/ 关键词检索 + 混合检索(RRF + 闸门 + 重排)
├── Store/ FileStore(JSONL 单文件向量库)
└── Tools/ Function Calling
想换存储(pgvector / Qdrant / ES)就实现 VectorStoreInterface;
想换模型厂商就实现对应的 Driver 接口。
九、几个必须知道的坑
- 换向量厂商/模型 = 坐标系变了,索引必须重建(本包会自动全量重建,但你得知道为什么变慢了/为什么花钱了)。
- DeepSeek 没有 embedding 接口,向量必须第二家。
- 百炼的重排端点是
compatible-api,对话和向量是compatible-mode,写错就是 404。 - PHP-FPM 里同步调 AI 会阻塞整站(一次问答 5~10 秒),要么加超时+队列,要么独立部署。
- Key 别写进代码,走环境变量或配置中心;别把 Key 提交到 Git。
也别把
storage/提交上去(里面有索引文件)。 - 工具里查数据必须带归属条件(
where user_id),否则等于把数据库开放给所有人。 - 单文件向量库适合 1 万块以内;超了换 pgvector。
- sync 是清单式的:本次没带的来源会被移除。目录 + 数据库要一次给全,别分两次。
十、测试
php tests/run.php
166 项离线断言,不联网、不产生任何 API 费用,覆盖:
| 分组 | 内容 |
|---|---|
| ①~⑨ | 配置解析、中文切块、关键词检索、混合检索与闸门、重排降级、存储读写、提示词、工具 Schema、门面装配 |
| ⑩ | 用假模型驱动跑通多轮 / 工具循环 / 流式,含「追问改写」和「话题切换不被污染」两组对照 |
| ⑪ | 回归:本包历史上修过的 10 个真 bug,每个都有对应断言防止改回去(含报错文案可读) |
| ⑫ | 配置简化:字符串简写、顶层 key、环境变量兜底、rerank 自动开关、ollama 免 Key |
| ⑬ | 文档增量同步:新增/更新/删除/未变、换配方全量重建、front-matter 剥离、auto_sync 关闭 |
| ⑭ | 默认值规则:填了就不改、默认模型跟厂商配套、维度按模型查表、猜不出就报错、usedDefaults 自查 |
另有文档一致性审计(composer audit-docs),专门防止文档和代码各说各话:
php tests/audit.php # 或 composer audit-docs
注意:脚本名不能叫
audit,会被 Composer 自带的composer audit(依赖漏洞检查)覆盖掉而静默失效,所以这里叫audit-docs。
它从代码、配置模板、README 三方抽取后交叉比对,查七类问题:
① 代码在读但文档没写的配置项 ② 文档写了但代码不读(配了没反应)③ 模板写了代码不读
④ 代码读了模板没列 ⑤ 默认值代码与文档不一致 ⑥ 文档/示例里用了不存在的方法
⑦ README 的目录结构图与真实 src/ 不符。
建议每次改完配置或文档都跑一遍 —— 这类漂移肉眼看不出来,但用户照文档配就是没反应。
真要验证线上能不能用,还得像
examples/那样用真 Key 跑一遍。 离线测试保证的是「逻辑不坏」,代替不了「厂商通不通」。
十一、已知边界(别指望它做这些事)
诚实清单,省得你踩进去才发现:
- 流式不统计 token。
stream()返回的Answer::$usage全是 0 —— SSE 流里 DeepSeek 默认不带 usage。 要对账就用ask(),或者自己去算字符数。 - 追问改写是启发式的,靠「≤25 字 + 含承接词」判断,不是让模型改写。
想要更准就自己接一个改写调用,把改写后的句子传给
retrieve()。 - 单文件向量库是内存线性扫描,1 万块以内可以,再大必须换 pgvector / Qdrant(实现
VectorStoreInterface)。 - 关键词检索用二字滑窗,没有 jieba 分词。多数场景够用, 但遇上「番茄炒蛋」vs「炒番茄」这种词序变化会失效。
- 闸门阈值必须你自己测。0.45 是本仓库样例数据上的分布,换资料要重测(见 gate 怎么定)。
- 只处理纯文本。PDF/Word/表格要先自己抽成文本再丢进来。
- 不做鉴权、不限流、不审计。这些是宿主项目的责任。
十二、License
MIT