Skip to content

ATRI Agent 智能体模块 ​

ATRI Agent 是 ATRI-LK 的系统级智能体能力层,负责统一管理 LLM 模型、消息处理、记忆系统、工具调用和任务调度。它既支持基础文本对话,也可扩展为多角色、多模型协作的复杂场景。

模块定位 ​

ATRI.system.agent 是智能体能力的核心实现,主要职责包括:

  • 统一封装 LLM 与多种模型类型(聊天、工具、图片、嵌入、语音等)
  • 维护模型提供商配置和注册流程
  • 管理 Agent 历史对话、记忆、用户资料与任务调度
  • 提供函数调用与工具链接入能力
  • 对外暴露统一的 llm_manager 与插件配置入口

目录结构 ​

text
ATRI/system/agent/
├── __init__.py          # 模块初始化与导出(含 reload_all)
├── config.py            # Agent 配置与 Provider 加载逻辑
├── service.py           # 插件注册与命令入口
├── agent/               # Agent 实际运行逻辑
│   ├── chat_chain.py    # 对话链路
│   ├── function_calling.py  # 函数调用管理
│   ├── history.py       # 历史记录管理
│   ├── memory/          # 记忆系统
│   ├── schedule.py      # 任务调度
│   ├── sender.py        # 消息发送器
│   ├── file/            # 文件读取与目录查询
│   │   ├── reader.py    # 文件读取功能
│   │   └── tree_string.py # 目录树生成
│   ├── skills/          # 技能系统
│   │   ├── model.py     # Skill / SkillSource / 名称校验
│   │   ├── loader.py    # 内置与用户目录扫描、Markdown 解析
│   │   └── manage.py    # SkillManager 单例与开关读写
│   └── ...
├── brain/               # 多模型/多脑协作相关实现
│   ├── thinking.py      # 思考主循环(提示词、调用回填)
│   ├── action_parser.py # 动作解释器:把模型文本解析成功能调用
│   └── reply_judgment.py
├── llm/                 # 模型定义、Provider、工厂函数
│   ├── model.py         # ModelType、LLMManager、LLMResponse
│   ├── provider.py      # ProviderManager、LLMModel、ModelPricing
│   ├── contents.py      # 消息内容封装
│   ├── openai.py        # OpenAI/OpenAI-compatible 接口
│   ├── gemini.py        # Gemini 接口
│   ├── system_one.py    # SystemOne 接口
│   ├── embedding.py     # Embedding 模型
│   ├── tts.py           # TTS 模型
│   ├── token.py         # Token 统计
│   ├── log.py           # 模型输入输出独立日志
│   └── ...
├── utils.py             # 日志与通用请求封装
└── README.md            # 文档入口

关键概念 ​

LLM 模型类型 ​

模型类型由 ModelType 枚举定义,当前包含:

  • CHAT:通用聊天模型
  • IMAGE:图像处理模型
  • WORK:工作模型
  • TOOL:工具调用/函数调用模型
  • EMBEDDING:向量嵌入模型
  • TTS:语音合成模型
  • SYSTEMONE:SystemOne 相关模型

provider.yml 的 type 支持写列表,一次性把同一个模型注册成多种类型。未配置 type 的模型会被跳过。

LLMManager ​

LLMManager 是统一的模型调度器,负责:

  • 注册模型:register_model()
  • 获取模型:get_model() / get_models()
  • 按类型调用:call_model_by_type()
  • 按名称调用:call_model()

如果目标模型不存在,或没有注册该类型,将抛出 ModelRequestError / NO_MODEL_ERR。在调用失败时通常需要捕获该异常。

call_model() 返回的 LLMResponse 会由管理器回填实际应答的模型名(resp.model), 多模型轮询时调用方能据此按正确模型换算计费。计费模型是 llm.provider.ModelPricing (input / output / cache_hit,元/1M tokens),

python
pricing = llm_manager.get_model("siliconflow/DeepSeek-V4-Flash").pricing
cost = pricing.calculate_cost(prompt_tokens=1200, output_tokens=300, cache_hit_tokens=800)

缓存命中按输入的子集处理(超出输入时截断),不会与输入重复计价。

LLMContents / LLMMessages ​

模块提供了结构化消息内容对象,适合构造复杂的请求:

  • LLMContents:单一内容对象
  • LLMMessages:多轮消息集合
  • LLMMessage:单条消息

这样可以方便组装多角色对话、工具调用上下文、系统提示等信息。

Provider 与配置 ​

模型提供商由 LLMProvider 和 LLMModel 定义:

  • LLMProvider:描述 provider,例如 OpenAI-compatible API、Gemini 等
  • LLMModel:描述某个具体模型实例及其参数
  • ProviderManager:统一管理 provider 和 model 注册

在启动或 reload 时,会从系统配置目录读取 provider.yml,并通过 load_provider_from_config() 注册所有模型。

模型调用日志 ​

模型输入输出不进入主日志,而是直接写入独立目录 data/logs/llm:

  • 日志文件直接放在该目录下,每次启动新建 llm-<启动时间>.log(重名时追加 -1、-2),滚动保留最近 3 次启动的日志;
  • 单个日志文件最大 10MB,溢出后自动轮转;
  • 每次调用按文本块记录:时间、模型、类型、事件、耗时、Token 用量、完整输入与输出;失败调用记录异常;
  • 单段文本最多 10 万字符、单条记录最多 50 万字符,超出截断,避免把文件顶过阈值;
  • 图片/音频等二进制内容只记录类型与长度,不写入 base64;
  • 主日志只保留一行调用摘要(模型、事件、耗时、Token、成败),不再输出完整输入输出。

隔离靠 ATRI.log 的 LLM_LOG_EXTRA 标记:LoguruNameDealer 遇到带该标记的记录直接返回 False, 因此它们只会落到 data/logs/llm 的 sink,不会重复写进 info/ / debug/ 等按级别分的文件。

日志由 LLMManager.call_model() 统一写入(init_llm_log() 在 agent 的 init() 里调用一次),其他调用方无需手动记录。

配置项 ​

ATRI.system.agent.config 中提供了 Agent 运行参数:

字段顺序 = 界面顺序

_fields 由 __init_subclass__ 按类体定义顺序收集,describe() 也按此顺序输出, 前端直接照此渲染。所以调整字段顺序就是调整 WebUI 里的显示顺序—— 当前按「使用频率」排列:模型选择 → 上下文参数 → 主动行为 → 搜索 → 能力开关 → 数据收集。

python
class AgentConfig(PluginConfig):
    # ---- 模型选择(最常改)----
    model_chat = Field(str, "", "聊天模型")
    model_tool = Field(str, "", "工具模型")
    model_image = Field(str, "", "图片模型")
    model_tts = Field(str, "", "语音模型")
    model_embedding = Field(str, "", "向量模型")
    # ---- 上下文与思考 ----
    max_history = Field(int, 30, ...)
    max_thinking_times = Field(int, 20, ...)
    # ---- 主动行为 ----
    proactive_max_per_hour = Field(int, 3, "主动发言上限", ..., min=1)
    proactive_cooldown = Field(int, 300, "主动发言冷却", ..., min=0)
    # ---- 联网搜索 ----
    search = Field(
        dict,
        "搜索API配置",
        children={
            "enable": Field(bool, False, "启用联网搜索"),
            "provider": Field(str, "tavily", "搜索服务"),
            "api_key": Field(str, "", "搜索 API 密钥"),
        },
    )
    # ---- 能力开关(只记例外)----
    function_calling_disable = Field(list, [], "禁用的功能调用", ..., item_type=str)
    function_plugin_disable = Field(list, [], "禁用的功能插件", ..., item_type=str)
    skills_disable = Field(list, [], "禁用的技能", ..., item_type=str)
    skills_embed = Field(list, [], "内嵌提示词的技能", ..., item_type=str)
    # ---- 行为数据收集 ----
    telemetry_enabled = Field(bool, True, "行为数据收集")
    telemetry_include_content = Field(bool, False, "数据收集包含正文")

含义:

  • model_chat / model_tool / model_image / model_tts / model_embedding:指定某类型使用哪个模型(填注册名)。留空 = 按默认分配。这些字段带 options_provider,导出 schema 时会列出该类型下已注册的模型,前端因此渲染成下拉框而非输入框
  • max_history:最大历史消息数(默认30),控制上下文窗口大小
  • max_thinking_times:Agent 的最大思考轮数(默认20)
  • proactive_max_per_hour / proactive_cooldown:主动发言的硬限流(每小时上限、最小间隔)。只约束主动插话,被 @ 或被叫到不受限
  • search.enable / search.provider / search.api_key:联网搜索能力配置
  • function_calling_disable / function_plugin_disable / skills_disable / skills_embed:四个**"只记例外"**的列表,见下文开关说明。function_calling_disable 里写核心功能(stop_thinking / send_message)无效,注册时会被剔除
  • telemetry_enabled / telemetry_include_content:行为数据收集开关。数据写在 data/plugins/agent/telemetry/ 下的按天 jsonl(七类事件:turn / tool / judge / initiative / feel / memory / decay),单文件 1MB 滚动、保留 14 天。默认不记消息正文,telemetry_include_content 打开后才写 120 字片段

动态选项(options_provider) ​

Field 支持 options_provider 回调:() -> list[dict],每次 describe() 时调用。 用于选项来自运行时的场景(如可选模型清单来自 provider.yml)。

  • 回调抛异常时该字段退化为普通输入框并打 warning,不会连带整个配置页报错;
  • 前端只判断 field.options?.length,所以后端填了选项就自动变成下拉框,前端无需改动。

模型类型与选择优先级 ​

call_model_by_type() 的选择顺序(llm/model.py):

  1. 配置指定(set_preferred):固定用它,失败才按序转移;
  2. 确定性类型(DETERMINISTIC_TYPES,目前只有 embedding):按注册顺序取第一个。 这是「没配就等于固定」的兜底——向量必须始终落在同一语义空间;
  3. 其余类型:随机打散做负载均衡。

embedding 之所以不能随机:换模型后新旧向量来自不同语义空间,余弦相似度会变成 看起来合理的随机数,而且不报错。记忆写入时会把 embedding_model 记进 metadata, 检索时按它过滤——换过模型的旧记忆会检索不到(安全降级),需要全量重建才能恢复。

改动顺序有硬约束

apply_runtime_config() 里的模型指定要按名字到已注册模型里查, 所以必须 load_models_from_config() 在前、apply_runtime_config() 在后。 service.init() 与 reload_all() 都已按此顺序排列,新增配置项时注意别打破它。

新增配置项要记得同步运行时

凡是「配置项 → 运行时单例参数」的连线,都要写进 config.apply_runtime_config(), 并在 service.init() 与 reload_config() 两处调用。只挂 reload 的话, 用户改了配置要手动 /agent.reload 才生效——对「关掉数据收集」这类开关是不可接受的。

引用陷阱

PluginConfig.get() 返回的是内部数据的引用,对返回值原地 append / remove 不会触发落盘。修改列表类配置必须构造新列表再 set:

python
# 错误:改动只存在于内存
config.get("function_calling_disable").append("search")

# 正确
names = list(config.get("function_calling_disable") or [])
names.append("search")
config.set("function_calling_disable", names)

FunctionCallingManager.set_manually_enabled() 与 SkillManager 内部已按正确写法实现,扩展时请遵守同样的约定。

快速开始 ​

聊天模型调用 ​

python
from ATRI.system.agent.llm import ModelType, llm_manager

resp = await llm_manager.call_model_by_type(ModelType.CHAT, "你好!")
print(resp.content)

复杂消息构造 ​

python
from ATRI.system.agent.llm import LLMContents

content = LLMContents().text("你好!请用中文回复我。")
python
from ATRI.system.agent.llm import LLMMessage, LLMMessages, LLMContents

messages = LLMMessages().add_message(
    LLMMessage(role="user", content=LLMContents().text("你好!"))
)

指定模型调用 ​

python
from ATRI.system.agent.llm import llm_manager

model = llm_manager.get_model("siliconflow/DeepSeek-V4-Flash")
resp = await llm_manager.call_model(model, "请总结一下机器学习的核心思想。")
print(resp.content)

默认 Provider 说明 ​

默认 provider 在 ATRI.system.agent.llm.provider 中定义,示例为 siliconflow,使用 OpenAI 兼容接口:

python
default_provider = LLMProvider(
    provider_name="siliconflow",
    provider_type="openai",
    url="https://api.siliconflow.cn/v1",
    models=[
        LLMModel(
            name="siliconflow/DeepSeek-V4-Flash",
            model="deepseek-ai/DeepSeek-V4-Flash",
            temperature=0.7,
            type="chat",
        ),
    ],
)

load_models_from_config() 会读取 provider 配置并把模型注册到 llm_manager 中,从而实现统一调用。

Agent 运行流程 ​

ATRI Agent 的典型生命周期大致如下:

  1. 启动插件时执行 init()
  2. load_models_from_config() 自动加载 provider 与 model
  3. register_function_calling() 注册功能调用
  4. agent.daily_task.init() 注册日常任务与运行逻辑
  5. 对话链路通过 ATRIAgent / ChatHistoryManager / MemoryManager 获取上下文
  6. 工具调用或多模型推理
  7. 对处理结果进行返回、记忆落库或进一步调度

记忆与历史 ​

agent 子模块中包含:

  • ChatHistoryManager:管理聊天历史(使用 chat_id 而非 group_id,支持更多场景)
  • MemoryManager:记忆存储与总结
  • UserProfile:用户资料与偏好
  • FaceManager:表情/消息风格管理
  • ATRISchedule:计划与日程调度

这让 Agent 不只是单轮生成器,而是具备一定长期交互状态的智能体。

功能调用 ​

Agent 支持多种功能调用,包括:

  • 网络搜索:搜索实时信息
  • 图片识别:识别图片内容
  • 日程管理:管理亚托莉的日程安排
  • 表情发送:发送亚托莉表情包
  • 词语解释:解释特定词语或概念
  • 服务查询:查询可用的服务列表
  • 目录查询:查询项目目录结构
  • 文件读取:读取指定文件内容
  • 技能查询:list_skills / get_skill_content,列出并读取技能正文

功能调用开发指南 ​

1. 定义一个功能调用 ​

继承 FunctionCalling 并实现 call,可选覆写 can_enable / is_available_for:

python
from typing import ClassVar

from ATRI.system.agent.agent.function_calling import (
    EnableCheck,
    FunctionCalling,
    FunctionCallingArg,
    FunctionCallingData,
    FunctionCallingManager,
)


class MyFunctionCalling(FunctionCalling):
    function_name = "my_function"
    """⚠️ 持久化键:手动开关按这个名字记录,一经发布不可改名"""
    description = "这个功能做什么,以及什么时候应该调用它"
    args: ClassVar[list[FunctionCallingArg]] = [
        FunctionCallingArg(name="text", _type="str", description="参数说明")
    ]

    @staticmethod
    async def call(data: FunctionCallingData):
        text = data.data.get("text", "")
        return f"收到:{text}"


FunctionCallingManager.register(MyFunctionCalling)

FunctionCallingData 提供的字段:

  • sender_id:对话人 ID(等价于 user_id)
  • chat_id:会话 ID
  • sender:ChatSender 实例(可能是 None,后台/测试调用时必须容忍)
  • data:DSL 解析出的参数字典
  • current_history:当前 History,可能为 None

2. 两段式可用性:can_enable 与 is_available_for ​

新增功能不要在注册处写 if 来决定"要不要注册"。注册是无条件的,是否可用改由两个钩子在运行时判定:

钩子判定维度典型用途
can_enable() -> EnableCheck① 当前环境/配置是否支持(与对话人无关)缺 TOOL 模型、未配置搜索密钥、需要 EMBEDDING 模型
is_available_for(sender, user_id) -> bool② 当前对话人是否可用只支持 OneBot 适配器、只对特定用户开放
python
class SearchFunctionCalling(FunctionCalling):
    function_name = "search"
    description = "联网搜索"

    @classmethod
    def can_enable(cls) -> EnableCheck:
        if not config.get("search.enable"):
            return EnableCheck(available=False, reason="未启用联网搜索")
        if not config.get("search.api_key"):
            return EnableCheck(available=False, reason="未配置搜索 API 密钥")
        return EnableCheck()
python
class SendFaceFunctionCalling(FunctionCalling):
    function_name = "send_face"
    description = "发送表情"

    @classmethod
    def is_available_for(cls, sender, user_id: str) -> bool:
        return isinstance(sender, OneBotChatSender)

约定:

  • reason 是给用户看的中文短句,会直接显示在 WebUI 的灰显 tooltip 上,也会拼进 do_action 的反馈文本;不要写英文异常名,不要留空。
  • can_enable() 内部抛异常会被 FunctionCallingManager 捕获并降级为 EnableCheck(available=False, reason="环境可用性检查失败"),因此在钩子里做"探测"是安全的。
  • is_available_for 抛异常同样会被捕获并返回 False。
  • 两个钩子都默认返回可用,因此不覆写的外部子类行为与旧版本完全一致(向后兼容硬要求)。

3. 最终可用性是三条件交集 ​

FunctionCallingManager.is_available(name, sender, user_id) 判定:

text
name ∉ config.get("function_calling_disable")   # ③ 手动开关(默认启用)
  ∧ can_enable().available                       # ① 环境
  ∧ is_available_for(sender, user_id)            # ② 对话人
  • get_available(sender, user_id) 返回上述交集,并保持注册顺序(也就是提示词里的顺序)。
  • get_can_enable(name) 只做 ①,供 UI 展示"为什么不可用",与 ③ 无关。
  • call() 在不可用时抛 FunctionCallingUnavailableError;功能名完全未注册时仍抛 KeyError。
  • do_action 会区分两种情况:未注册 → 未知的调用功能:X;已注册但不可用 → 功能 X 当前不可用:{reason}。

4. 核心功能(essential):③ 之外的例外 ​

stop_thinking 与 send_message 是"话说完 / 收尾"这两步,关掉就会让一轮思考既发不出消息也停不下来,因此声明为核心功能:

python
class SendMessageFunctionCalling(FunctionCalling):
    function_name = "send_message"
    essential = True   # 核心功能:手动开关恒为开,不能被关闭

essential = True 的影响:

  • is_manually_enabled(name) 直接返回 True,不看 function_calling_disable;
  • set_manually_enabled(name, False) 抛 FunctionCallingEssentialError,配置不落盘;
  • FunctionCallingManager.register() 会调 purge_essential_disables(),把核心功能名从禁用列表里剔除(兼容旧配置或手改 JSON);
  • WebAPI 的 get_function_callings 多返回一个 always_enabled,WebUI 据此把开关置灰并打「核心」标签;set_function_calling 对核心功能返回 400,set_settings 会剔除核心功能名并回一条 warning;
  • is_essential(name) 是唯一判定入口,未注册/未声明的功能一律返回 False,第三方子类不受影响。

只在这两个动作上标注 essential。send_md_message、send_face 这类"可选的表达方式"仍应保持可关闭。

手动开关的状态位置

function_calling_disable 只记录被关掉的名字,默认全开。这个列表按 function_name 记录,与代码实现无关,所以:

  • register_function_calling() 会先 clear_all() 再重新注册,但开关状态不会丢(它不在注册表里)。
  • reload_all() / 重启后开关状态同样保留。
  • 第三方插件自己注册的功能也会自动出现在 WebUI 的"功能调用"页,可直接开关。
  • 核心功能名即使被写进列表,也会在注册时被 purge_essential_disables() 剔除。

WebUI 接口不要重复注册

ATRI.system.webapi.api.tabs.agent.function_calling 中的接口只读注册表,绝不调用 register_function_calling()——那会 clear_all() 掉外部插件注册的功能。

动作解释器(brain/action_parser.py) ​

模型并不总是照着约定输出,解析层单独抽成 action_parser,原则是宁可宽容解析,也不让整轮思考崩掉:

text
split_response(text) -> (正文, 调用列表, 待回填的问题)
  • 只会返回三个值,不抛异常:单处调用解析失败收集进 issues,由 do_action 作为反馈回填给模型重试。
  • 按括号配平切分调用,允许调用跨行、字符串里直接换行;旧的逐行 ast 解析遇到换行会整块丢弃。
  • 合并多个代码块;解析不出调用的块按正文保留,不静默吞掉叙述。
  • 兼容模型自发的 DSML 原生工具调用,并把它连同参数值从正文里剥除。
  • 裸值按字符串回落,全角引号视同引号;类型别名归一(string → str、boolean → bool 等)。
  • 按功能声明的参数名与类型校验(normalize_args),问题直接变成可读提示回填,不必等 call() 抛 TypeError。

提示词侧也相应放宽:要求所有调用写在同一个代码块、禁止 XML/DSML 等其它格式,但字符串里直接换行现在也能被解析。

技能系统 ​

技能是面向 Agent 的玩法/操作说明文档(Markdown),让智能体在用户问"这个功能怎么用"时自己去查,而不必把这些长文本塞进每轮提示词。

目录与来源 ​

来源位置可写
内置res/data/agent/skills/*.md❌ 只读,随包分发
用户data/plugins/agent/skills/*.md(plugin.get_path()/skills)✅ 可通过 WebUI 增删改

技能名 = 文件名主干。同名用户文件覆盖内置,此时合并视图的类型为 modified(显示为"内置(已修改)"),删除用户文件即恢复内置正文。用户技能之间不允许同名(同一目录同一文件名物理上也不可能同名)。

文件格式:

markdown
---
description: 一句话说明这个技能讲什么
---

# 正文标题

正文内容(Markdown)

description 缺失时从正文首个非标题行截断回退。

两个独立开关 ​

两个开关都按技能名记录在 AgentConfig 的字符串列表里,与正文来自哪个目录无关:

开关存储位置默认效果
enabledskills_disable(记禁用)开启关闭后既不入提示词,也不能被 Agent 查询
embeddedskills_embed关闭开启后正文直接嵌入每轮提示词,且不可被 Agent 查询
text
enabled=false                  → 提示词 ❌  Agent 可查 ❌
enabled=true, embedded=false   → 提示词 ❌  Agent 可查 ✅(通过 list_skills 发现)
enabled=true, embedded=true    → 提示词 ✅(#你的技能 段)  Agent 可查 ❌

为什么要有内嵌

提示词里不列出可用技能的清单,是为了省 Token——Agent 通过 list_skills 功能按需发现技能。但有些内容(如"必须遵守的格式规范")需要每轮强制生效,这类短内容用 embedded。因此内嵌适合短小的强制规范类内容,过长会直接膨胀每轮提示词(WebUI 列有"正文字数"供参考)。

对外 API ​

python
from ATRI.system.agent.agent.skills import skill_manager

skill_manager.list_all()            # 合并视图全量(含未启用),UI 用
skill_manager.list_standalone()     # 不属于任何功能插件的技能,技能页用
skill_manager.list_queryable()      # enabled ∧ not embedded,list_skills 功能用
skill_manager.get("farm")           # 生效版本(用户覆盖优先)
skill_manager.get_embedded_prompt() # enabled ∧ embedded 的正文拼接

skill_manager.save_user("farm", "描述", "正文")   # 只写用户目录
skill_manager.delete_user("farm")                # 只删用户目录;纯内置会抛 ValueError
skill_manager.set_enabled("farm", False)
skill_manager.set_embedded("farm", True)

save_user / delete_user 之后会 invalidate() 缓存(只丢磁盘扫描结果,不惊动功能插件)。被删除的是纯用户技能时,其名字会同时从 skills_disable / skills_embed 中清理;被删除的是覆盖内置的技能时,开关状态保留(开关按名生效,不随内容消失)。

使用技能的成员 ​

  • thinking.get_prompt() 在 #你的技能 段注入内嵌正文。
  • list_skills / get_skill_content 两个功能调用负责 Agent 侧的按需查询。
  • list_skills 被手动关闭时,Agent 将无法得知任何非内嵌技能,WebUI 的"功能调用"页会给出提示。

功能插件(FunctionPlugin) ​

功能插件把技能与功能调用打成一组,用一个开关统一控制。

注册完全由插件负责:插件里写什么,注册时就替你登记什么。你不需要另外调用 FunctionCallingManager.register(),也不需要自己去 skill_manager.save_user()—— FunctionPluginManager.register() 会完成这些动作。

声明并注册 ​

python
from typing import ClassVar

from ATRI.system.agent.agent.function_calling import (
    FunctionCalling,
    FunctionCallingArg,
)
from ATRI.system.agent.agent.function_plugin import (
    FunctionPlugin,
    PluginSkill,
    function_plugin_manager,
)


class TrpgRoll(FunctionCalling):
    function_name = "trpg_roll"
    description = "掷骰"
    args: ClassVar[list[FunctionCallingArg]] = [
        FunctionCallingArg(name="expr", _type="str", description="如 1d20")
    ]

    @staticmethod
    async def call(data):
        return f"结果:{data.data.get('expr')}"


class TrpgNpc(FunctionCalling):
    function_name = "trpg_npc"
    description = "生成 NPC"
    args: ClassVar[list[FunctionCallingArg]] = []


function_plugin_manager.register(
    FunctionPlugin(
        name="trpg",
        description="跑团玩法",
        skills=[
            PluginSkill(
                name="trpg_basics",
                description="跑团基础玩法",
                content="# 跑团基础玩法\n\n用 trpg_roll 掷骰,用 trpg_npc 生成 NPC。",
            )
        ],
        functions=[TrpgRoll],       # 常用:随插件进提示词
        rare_functions=[TrpgNpc],   # 不常用:只能经 call 调用
    )
)

register_function_plugin(...) 是同一件事的便捷写法,参数一致:

python
register_function_plugin(
    name="trpg",
    description="跑团玩法",
    skills=[PluginSkill(name="trpg_basics", content="...")],
    functions=[TrpgRoll],
    rare_functions=[TrpgNpc],
)

成员可以「自带」也可以「引用」 ​

写法谁负责登记
PluginSkill(...)插件注入内存(不落盘)
FunctionCalling 子类插件登记进 FunctionCallingManager
字符串只登记归属,引用已经存在的内容

引用式写法适合"正文/实现已经在别处,只想归到某个插件下"的场景:

python
FunctionPlugin(name="farm", description="农场玩法", skills=["farm"])

(目前内置技能都没有这样收编——farm 等保持为普通内置技能。)

要点:

  • 传类不传名字(自带实现时):拼错会立刻报错,而不是在运行时变成一个查不到的名字;
  • 技能正文会落盘:每个 PluginSkill 都会写成技能文件(等同 skill_manager.save_user), 所以重载后依然在,不需要你另外分发 md;
  • 顺序有讲究:内部按「写技能 → 登记功能 → 登记插件声明」执行,任一步失败会回滚 已写入的部分,不会留下"插件已注册但成员不存在"的半成品;
  • 不会重置开关:重复注册(例如每次 register_function_calling() 都跑一遍)不会把 用户关掉的 function_calling_disable / skills_disable / skills_embed 又打开;
  • 同一功能不能同时出现在 functions 与 rare_functions(ValueError);
  • 成员不能被两个插件同时认领:归属是判定的唯一依据,重复会让结果取决于注册顺序, 因此注册时直接抛 ValueError;同名插件重复注册视为覆盖(重载场景),不受此限制。

重载与卸载 ​

python
function_plugin_manager.reload("trpg")    # 撤回旧成员 → 按最新声明重新注册
function_plugin_manager.unload("trpg")    # 完全撤回
function_plugin_manager.unload_all()      # 撤回全部已注册插件

unload() 是完全撤回,会连带清理插件自己登记的成员:

成员来源卸载时的处理
PluginSkill(插件自带正文)从内存摘除(从未写过磁盘),并清掉 skills_disable / skills_embed 里的名字
FunctionCalling 子类(插件自带实现)从 FunctionCallingManager 摘除
字符串(引用已有内容)不动——它本来就不属于这个插件

remove_skills=False 时只撤功能类、保留内存技能。

插件技能不落盘

PluginSkill 的正文只在注册时注入内存(SkillSource.VIRTUAL,列表里显示为「插件提供」), data/plugins/agent/skills/ 下不会出现任何文件。这样做的好处是插件技能的 生命周期完全由插件掌握:卸载即消失,不会留下无人维护的孤儿文件。

代价是它们只活在进程内存里:skill_manager.invalidate() 不会碰它们 (只丢磁盘扫描缓存),内容随插件注册而存在、随卸载而消失; 进程重启时依赖插件在启动时重新注册。

重载顺序

agent.reload_all() 的步骤是:重读配置 → register_function_calling()(内部 clear_all() 会清空所有功能)→ function_plugin_manager.reload_all() → 清技能扫描缓存。

第三步是必须的:第二步把插件成员一起清掉了,只有显式重放才能把它们按各自的 原始声明补回来。技能缓存那边只调 skill_manager.invalidate(),不承担插件重放职责。

reload() 内部先摘掉旧声明、再用同一份声明重新注册;如果注册失败会把旧声明放回去, 不会让插件凭空消失。

unregister() 不是卸载

unregister() 只摘掉登记表里的那条声明,不撤回技能文件与功能类。它只用于 测试或临时让出归属;真要卸载请用 unload()。clear_all() 同理。

技能正文的三种带法 ​

写法正文位置适用
skills=["name"]通用技能目录(res/data/agent/skills/ 或用户目录)随包分发、要被技能页看到的既有技能
skills=[PluginSkill(...)]内联在声明里很短、写在代码里更顺手的正文
skill_files=[PluginSkillFile(path=...)]插件自有目录插件自己的说明书,不希望混进通用技能目录

后两种都只驻留内存,不会往 data/plugins/agent/skills/ 写字。

第一种(按名字引用)适合把已有的内置技能归到某个插件下。目前内置技能 (farm / fishing / checkin / chat_group)都没有这样做——它们是普通内置技能, 由技能系统直接扫描,在技能页可见。要收编时这样写:

python
FunctionPlugin(
    name="farm",
    description="农场玩法",
    skills=["farm"],             # 引用通用技能目录里的既有技能
)

内置的 file_ops 属于第三种:正文放在 res/data/agent/function_plugins/file_ops/, 通用技能目录里没有它,技能系统不会自动扫描到;插件注册时把文件读进内存:

python
FunctionPlugin(
    name="file_ops",
    description="文件操作:查目录树与读文件。仅由智能体自查代码/配置时使用",
    skill_files=[
        PluginSkillFile(path=FUNCTION_PLUGIN_SKILLS_DIR / "file_ops" / "file_ops.md")
    ],
    rare_functions=[GetDirTreeFunctionCalling, ReadFileFunctionCalling],
)

为什么要把插件技能单独放一个目录

放在 res/data/agent/skills/ 下的 md 会被技能系统无条件扫描,于是: 它在技能页上表现为「内置」而不是「插件提供」;卸载插件也无法真正撤回它 (文件还在,技能系统照样能读到)。移到插件自有目录后,正文的可见性完全由 插件控制——注册时进内存、卸载时消失,res/data/agent/skills/ 只留给 真正"随包分发、独立于任何插件"的技能。

注册走 function_plugin_manager.register(plugin);内置插件都在 agent/function_plugin/builtin.py 里声明,由 register_function_calling() 一并注册。

两种方式怎么选

插件自带技能正文与功能实现 → 直接写在 FunctionPlugin 里(或 register_function_plugin); 只是给已有内容分组 → 成员写名字即可。

两种可以混用:同一个插件里既有 PluginSkill、也有字符串引用。

归属与判定的优先级 ​

归属只由插件声明(成员自己不声明所属插件),所以一个名字要么属于某个插件、要么是独立内容。判定链是:

text
插件整体开关(function_plugin_disable)
  → 成员自己的开关(function_calling_disable / skills_disable / skills_embed)
  → 环境可用性 / 对话人可用性
  • 插件关掉时,成员一律失效(技能既不进提示词也不可查询;功能调用报"当前不可用"), 成员自己的开关状态不丢,插件重开后按原状恢复;
  • skills_embed 同样受插件开关约束,get_embedded_prompt() 不会注入已关闭插件的正文。

不常用功能与 call ​

rare_functions 里的功能不进常规功能提示词(get_available() 会过滤掉),只能通过元功能 call 调用:

text
call(function="trpg_npc", args={"name": "酒保"})
  • get_rare_available() 返回当前会话可经 call 调用的子集;call 自己永远不在这个集合里(避免绕过判定递归);
  • call 的 can_enable() 依赖这个集合:没有可用不常用功能时 call 自动从提示词消失;
  • call 的内部实现复用 normalize_args(按目标功能声明校验参数)与 FunctionCallingManager.call(复用可用性判定),所以它的报错与直接调用一致;
  • do_action 会把"直接调用不常用功能"拦下,并回填 请改用 call(...) 的提示。

WebUI 分工 ​

页面展示内容
功能插件全部插件(含插件内技能与功能调用),插件开关 + 成员的启用开关
功能调用plugin 字段为空的独立功能调用
技能list_standalone(),即可增删改的独立技能

成员开关走插件页的两个专用接口:

接口作用存储
set_plugin_skill_state插件内技能的启用开关skills_disable
set_plugin_function_state插件内功能调用的启用开关function_calling_disable

两者都复用成员自己的开关存储(按名字记录),所以"插件关了再开,成员状态还在"是自然结果, 不需要额外的状态迁移。接口一律返回整个插件对象,前端直接替换即可刷新该卡片。

接口上同步收紧:save_skill / delete_skill 对插件内技能返回 400;set_skill_state 只允许改「启用」,改「内嵌」同样 400;set_function_calling 对插件内功能返回 400, 提示去功能插件页操作(避免同一开关出现在两处)。

声明要跟着实际内容走

插件里写的技能名 / 功能名必须真的存在,否则该成员会在页面上标记为「未找到 / 未注册」, 并在注册时记一条 warning(function_plugin_manager.check_members())。这不会阻断启动—— 外部插件注册顺序不确定,硬失败会把一个笔误放大成起不来。

扩展建议 ​

如果需要扩展 Agent 能力,可以考虑以下几个点:

  • 新增 provider:在 provider.py 中注册新的 LLMProvider
  • 新增模型:在 provider.yml 中配置 models
  • 追加工具调用:扩展 agent/function_calling.py
  • 追加技能:在 res/data/agent/skills/ 放内置技能,或通过 WebUI 新建用户技能
  • 把一组能力打包:在 agent/function_plugin/ 声明 FunctionPlugin
  • 增加记忆策略:调整 agent/memory/ 模块逻辑
  • 增加会话/调度器:在 agent/schedule.py 或 chat_chain.py 中接入

常见异常 ​

  • ModelRequestError:模型调用失败,通常是网络、鉴权或 API 返回异常
  • ValueError:模型未注册或错误参数
  • NO_MODEL_ERR:没有可用模型

当使用 llm_manager.call_model_by_type() 或 call_model() 时,建议在业务层进行异常捕获与重试。

说明 ​

该模块适合在 ATRI-LK 中作为系统级 Agent 能力底座使用。它的设计目标是:

  • 统一抽象不同厂商的 LLM 接口
  • 兼容多种模型类型和调用场景
  • 简化 Agent 的消息、记忆和工具调用开发过程
  • 让业务层更容易使用可靠的智能体能力

如果你正在开发 ATRI-LK 的聊天机器人、自动任务处理或多模型协作流程,这个模块是核心入口之一。

Released under the CC-BY-SA-4.0 License.