Search by

progincc / ai-assistant

progincc

配置驱动的 PHP AI 助手:RAG 知识库 + Function Calling,框架无关,针对中文场景优化

dev-master 2026-09-23 01:56 UTC

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),必然误选噪声,答非所问

现在的策略是回退式

  1. 先拿本轮提问单独检索,有结果就直接用(覆盖绝大多数情况)
  2. 只有在「没检索到」「这句话像追问」(≤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 接口。

九、几个必须知道的坑

  1. 换向量厂商/模型 = 坐标系变了,索引必须重建(本包会自动全量重建,但你得知道为什么变慢了/为什么花钱了)。
  2. DeepSeek 没有 embedding 接口,向量必须第二家。
  3. 百炼的重排端点是 compatible-api,对话和向量是 compatible-mode,写错就是 404。
  4. PHP-FPM 里同步调 AI 会阻塞整站(一次问答 5~10 秒),要么加超时+队列,要么独立部署。
  5. Key 别写进代码,走环境变量或配置中心;别把 Key 提交到 Git。 也别把 storage/ 提交上去(里面有索引文件)。
  6. 工具里查数据必须带归属条件(where user_id),否则等于把数据库开放给所有人。
  7. 单文件向量库适合 1 万块以内;超了换 pgvector。
  8. 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 跑一遍。 离线测试保证的是「逻辑不坏」,代替不了「厂商通不通」。

十一、已知边界(别指望它做这些事)

诚实清单,省得你踩进去才发现:

  1. 流式不统计 tokenstream() 返回的 Answer::$usage 全是 0 —— SSE 流里 DeepSeek 默认不带 usage。 要对账就用 ask(),或者自己去算字符数。
  2. 追问改写是启发式的,靠「≤25 字 + 含承接词」判断,不是让模型改写。 想要更准就自己接一个改写调用,把改写后的句子传给 retrieve()
  3. 单文件向量库是内存线性扫描,1 万块以内可以,再大必须换 pgvector / Qdrant(实现 VectorStoreInterface)。
  4. 关键词检索用二字滑窗,没有 jieba 分词。多数场景够用, 但遇上「番茄炒蛋」vs「炒番茄」这种词序变化会失效。
  5. 闸门阈值必须你自己测。0.45 是本仓库样例数据上的分布,换资料要重测(见 gate 怎么定)。
  6. 只处理纯文本。PDF/Word/表格要先自己抽成文本再丢进来。
  7. 不做鉴权、不限流、不审计。这些是宿主项目的责任。

十二、License

MIT