ATRI Agent 智能体模块
ATRI Agent 是 ATRI-LK 的系统级智能体能力层,负责统一管理 LLM 模型、消息处理、记忆系统、工具调用和任务调度。它既支持基础文本对话,也可扩展为多角色、多模型协作的复杂场景。
模块定位
ATRI.system.agent 是智能体能力的核心实现,主要职责包括:
- 统一封装 LLM 与多种模型类型(聊天、工具、图片、嵌入、语音等)
- 维护模型提供商配置和注册流程
- 管理 Agent 历史对话、记忆、用户资料与任务调度
- 提供函数调用与工具链接入能力
- 对外暴露统一的
llm_manager与插件配置入口
目录结构
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),
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 里的显示顺序—— 当前按「使用频率」排列:模型选择 → 上下文参数 → 主动行为 → 搜索 → 能力开关 → 数据收集。
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):
- 配置指定(
set_preferred):固定用它,失败才按序转移; - 确定性类型(
DETERMINISTIC_TYPES,目前只有embedding):按注册顺序取第一个。 这是「没配就等于固定」的兜底——向量必须始终落在同一语义空间; - 其余类型:随机打散做负载均衡。
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:
# 错误:改动只存在于内存
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 内部已按正确写法实现,扩展时请遵守同样的约定。
快速开始
聊天模型调用
from ATRI.system.agent.llm import ModelType, llm_manager
resp = await llm_manager.call_model_by_type(ModelType.CHAT, "你好!")
print(resp.content)复杂消息构造
from ATRI.system.agent.llm import LLMContents
content = LLMContents().text("你好!请用中文回复我。")from ATRI.system.agent.llm import LLMMessage, LLMMessages, LLMContents
messages = LLMMessages().add_message(
LLMMessage(role="user", content=LLMContents().text("你好!"))
)指定模型调用
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 兼容接口:
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 的典型生命周期大致如下:
- 启动插件时执行
init() load_models_from_config()自动加载 provider 与 modelregister_function_calling()注册功能调用agent.daily_task.init()注册日常任务与运行逻辑- 对话链路通过
ATRIAgent/ChatHistoryManager/MemoryManager获取上下文 - 工具调用或多模型推理
- 对处理结果进行返回、记忆落库或进一步调度
记忆与历史
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:
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:会话 IDsender: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 适配器、只对特定用户开放 |
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()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) 判定:
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 是"话说完 / 收尾"这两步,关掉就会让一轮思考既发不出消息也停不下来,因此声明为核心功能:
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,原则是宁可宽容解析,也不让整轮思考崩掉:
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(显示为"内置(已修改)"),删除用户文件即恢复内置正文。用户技能之间不允许同名(同一目录同一文件名物理上也不可能同名)。
文件格式:
---
description: 一句话说明这个技能讲什么
---
# 正文标题
正文内容(Markdown)description 缺失时从正文首个非标题行截断回退。
两个独立开关
两个开关都按技能名记录在 AgentConfig 的字符串列表里,与正文来自哪个目录无关:
| 开关 | 存储位置 | 默认 | 效果 |
|---|---|---|---|
enabled | skills_disable(记禁用) | 开启 | 关闭后既不入提示词,也不能被 Agent 查询 |
embedded | skills_embed | 关闭 | 开启后正文直接嵌入每轮提示词,且不可被 Agent 查询 |
enabled=false → 提示词 ❌ Agent 可查 ❌
enabled=true, embedded=false → 提示词 ❌ Agent 可查 ✅(通过 list_skills 发现)
enabled=true, embedded=true → 提示词 ✅(#你的技能 段) Agent 可查 ❌为什么要有内嵌
提示词里不列出可用技能的清单,是为了省 Token——Agent 通过 list_skills 功能按需发现技能。但有些内容(如"必须遵守的格式规范")需要每轮强制生效,这类短内容用 embedded。因此内嵌适合短小的强制规范类内容,过长会直接膨胀每轮提示词(WebUI 列有"正文字数"供参考)。
对外 API
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() 会完成这些动作。
声明并注册
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(...) 是同一件事的便捷写法,参数一致:
register_function_plugin(
name="trpg",
description="跑团玩法",
skills=[PluginSkill(name="trpg_basics", content="...")],
functions=[TrpgRoll],
rare_functions=[TrpgNpc],
)成员可以「自带」也可以「引用」
| 写法 | 谁负责登记 |
|---|---|
PluginSkill(...) | 插件注入内存(不落盘) |
FunctionCalling 子类 | 插件登记进 FunctionCallingManager |
| 字符串 | 只登记归属,引用已经存在的内容 |
引用式写法适合"正文/实现已经在别处,只想归到某个插件下"的场景:
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;同名插件重复注册视为覆盖(重载场景),不受此限制。
重载与卸载
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)都没有这样做——它们是普通内置技能, 由技能系统直接扫描,在技能页可见。要收编时这样写:
FunctionPlugin(
name="farm",
description="农场玩法",
skills=["farm"], # 引用通用技能目录里的既有技能
)内置的 file_ops 属于第三种:正文放在 res/data/agent/function_plugins/file_ops/, 通用技能目录里没有它,技能系统不会自动扫描到;插件注册时把文件读进内存:
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、也有字符串引用。
归属与判定的优先级
归属只由插件声明(成员自己不声明所属插件),所以一个名字要么属于某个插件、要么是独立内容。判定链是:
插件整体开关(function_plugin_disable)
→ 成员自己的开关(function_calling_disable / skills_disable / skills_embed)
→ 环境可用性 / 对话人可用性- 插件关掉时,成员一律失效(技能既不进提示词也不可查询;功能调用报"当前不可用"), 成员自己的开关状态不丢,插件重开后按原状恢复;
skills_embed同样受插件开关约束,get_embedded_prompt()不会注入已关闭插件的正文。
不常用功能与 call
rare_functions 里的功能不进常规功能提示词(get_available() 会过滤掉),只能通过元功能 call 调用:
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 的聊天机器人、自动任务处理或多模型协作流程,这个模块是核心入口之一。