Yuxi 共享智能体资源选择的保存边界:多租户权限下的配置合并、保留与并发安全机制
【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi
本指南基于 Yuxi 项目的一则已落地实现决策(bug-fix 类型,Owner 为AgentRepository),深入讲解共享智能体在委托管理员(delegated manager)只拥有部分资源访问权时,保存配置如何做到「不丢失创建者的完整期望选择、不越权新增引用、并发下不丢失最新隐藏引用」,同时保持运行时只取交集的安全语义。读完本文,你将掌握config_json.context字段补丁合并算法、PostgreSQL 行锁保护、null/空列表/非空列表三种资源策略语义,以及前端「只提交变化字段」的配合实现,可直接迁移到同类多租户智能体平台的设计中。
背景问题:委托管理员保存配置为什么可能破坏共享智能体
在 Yuxi 的多租户模型里,一个智能体可以被创建者共享给其他用户,其中一部分用户还拥有「管理」权限(manage_scope),成为委托管理员。委托管理员与创建者通常拥有不同的资源访问范围:创建者可能选择了 10 个 Skill,而委托管理员只能访问其中 5 个。
此时出现了一个隐蔽的数据完整性问题:
共享智能体保存完整期望配置,委托管理员只能访问其中部分资源。编辑页按当前候选项过滤后整体保存,会删除不可见的既有选择,影响创建者后续运行。
换句话说,如果保存接口把前端提交的配置当作整体覆盖来处理,那么委托管理员在编辑页上只看到 5 个可访问的 Skill,保存时提交的列表就只剩这 5 个——创建者选中的另外 5 个 Skill 被静默删除,创建者后续运行该智能体时行为被改变。这是原始缺陷(对应需求 xhome #61)的核心诉求:委托管理员保存无关配置时,必须保留完整选择,而运行时继续只使用当前操作者可访问的资源。
更麻烦的是,资源引用往往交错排列(例如["visible-a", "hidden-a", "visible-b", "hidden-b"]),简单的前置/后置重排都会改变 Skill 与预加载说明的加载顺序,影响运行行为;而直接 API 调用也可以绕过前端过滤,构成越权写入通道。
决策总览:字段补丁 + 行锁合并 + 运行时交集
围绕上述问题,决策确立了四个相互配合的支柱:
- 保存接口把
config_json.context当作字段补丁处理:省略字段保留原值,只有显式提交的字段才参与合并(对应 agent_repository.py 中的merge_agent_config_json)。 - 写入前按 Context Schema 和用户角色过滤可写字段,并复用运行时资源选项解析访问范围(对应 agent_config_service.py 的
prepare_agent_config_write)。 AgentRepository在 PostgreSQL 行锁内读取最新配置并合并,保护并发提交场景下最新的隐藏引用不被旧快照覆盖(对应 agent_repository.py 的update方法)。- 运行期继续计算期望选择与操作者可访问资源的交集,且运行归一化不改写持久配置(对应 context.py 的
normalize_agent_context_config)。
用户操作参考统一维护在配置智能体 — 资源选择语义一节。
后端核心:merge_agent_config_json合并算法
合并是保存边界的核心实现,位于 agent_repository.py。整体流程:
- 深拷贝当前持久配置与提交补丁,顶层浅合并(
merged = {**current, **patch_copy});若补丁未包含context字段,直接返回——这意味着保存名称、模型等非 context 字段完全不会触碰资源选择。 - 对
context内部同样做字段级合并:merged_context = {**current_context, **patch_context},未提交的字段保留原值。 - 只对「提交了且属于资源字段集合」的字段执行资源引用合并。
资源字段集合由yuxi.agents.context导出:
# backend/package/yuxi/agents/context.py#L397-L399 _DEFAULT_ALL_CONTEXT_FIELDS = frozenset({"tools", "knowledges", "mcps", "skills"}) _EMPTY_ALL_CONTEXT_FIELDS = frozenset({"subagents"}) AGENT_RUNTIME_RESOURCE_FIELDS = _DEFAULT_ALL_CONTEXT_FIELDS | _EMPTY_ALL_CONTEXT_FIELDS保存边界在运行时字段集之上额外处理预加载 Skill:
# backend/package/yuxi/repositories/agent_repository.py#L110 AGENT_RESOURCE_CONFIG_FIELDS = AGENT_RUNTIME_RESOURCE_FIELDS | {"preload_skills"}非空列表:可见增删 + 隐藏引用保留 + 交错顺序稳定
对于提交的非空资源列表,合并逻辑如下(merge_agent_config_json中的核心分支):
- 权限前置校验:每个资源字段必须出现在
resource_access中,否则抛错(智能体资源字段 {field} 未经过权限校验),杜绝绕过权限解析的直接写入。 - 拒绝无权新增:新请求中的每一项,如果既不在当前操作者可访问集合中、也不在既有引用集合中,抛
ValueError(无权新增智能体资源)。这条规则堵住了「借既有隐藏引用混入新引用」的越权通道。 - 保留旧引用:
kept_existing保留所有不可访问的旧引用(即使本次未提交)以及仍被请求保留的可见项,且维持原相对顺序。 - 追加新选择:
new_visible只包含可访问且此前不存在的新项,按请求顺序追加到末尾。
最终合并结果 =[保留的旧引用(原顺序), 新增可见项(请求顺序)]。这保证了 Skill 和预加载说明的加载顺序不因委托管理员编辑而被打乱。
显式 null 与空列表:整体切换资源策略
null与空列表是显式的策略值,表示整体切换该字段的资源策略,不进入「保留隐藏引用」分支:
- 工具、知识库、MCP、Skill:显式空列表表示「禁用该类资源」(而省略字段或
null则保持原值)。 - 子智能体(subagents):保留空列表即「全部可访问」的兼容语义——
null与空列表都展示全部可访问子智能体。
从 context.py 可以看到运行时对这组语义的对称处理:subagents的空列表会在归一化时被转换为None(全部可访问),而tools/knowledges/mcps/skills的空列表保持[](不启用)。
合并算法的单元测试佐证
test_agent_repository.py 中的测试精确刻画了这些规则:
def test_merge_agent_config_json_preserves_omitted_context_fields_and_hidden_skills(): """省略字段和不可见 Skill 引用均保持原值。""" merged = merge_agent_config_json( {"context": {"model": "provider:model-a", "skills": [f"skill-{i}" for i in range(10)]}, "metadata": {"source": "owner"}}, {"context": {"temperature": 0.2, "skills": [f"skill-{i}" for i in range(5)]}}, resource_access={"skills": {*(f"skill-{i}" for i in range(5))}}, ) assert merged["context"]["skills"] == [f"skill-{i}" for i in range(10)] # 10 项全部保留交错顺序测试则断言:["visible-a", "hidden-a", "visible-b", "hidden-b"]在提交["visible-b", "visible-c", "hidden-a"]后合并为["hidden-a", "visible-b", "hidden-b", "visible-c"]——隐藏引用保持相对顺序,新增可见项追加到末尾。preload_skills的保留独立于skills允许列表,互不干扰。
写入前的可写字段过滤与访问范围解析
保存接口并非直接把请求透传给 repository。agent_config_service.prepare_agent_config_write(agent_config_service.py)在合并前完成两道检查:
- 按角色过滤可写字段:调用
filter_config_by_role,依据 Context 字段的metadata.auth决定哪些字段可写——admin字段仅管理员/超级管理员可写,superadmin字段仅超级管理员可写,普通用户提交的越权字段会被过滤,不会进入合并(该语义在 context.py 的_role_can_modify中定义)。注意auth只限制修改权限,不提供字段保密能力。 - 解析本次提交字段的可访问键:对提交的非空资源字段调用
resolve_agent_resource_options(context.py),按当前操作者身份解析出可访问的工具、知识库、MCP、Skill、子智能体键集合,组装成resource_access传给merge_agent_config_json。其中preload_skills复用skills的解析结果。
也就是说,「谁能改」「能改哪些值」都基于操作者当前身份实时解析,而非依赖前端提交的候选列表,从后端杜绝了直接 API 调用的越权写。
并发安全:PostgreSQL 行锁内的读-合并-写
委托管理员与创建者可能并发保存同一共享智能体。若合并基于陈旧快照,后提交者可能覆盖先提交者刚写入的隐藏引用。AgentRepository.update的解决方式(agent_repository.py):
if config_json is not None: result = await self.db.execute(select(Agent.config_json).where(Agent.id == agent.id).with_for_update()) row = result.one_or_none() if row is None: raise ValueError("智能体不存在") agent.config_json = merge_agent_config_json(row[0], config_json, resource_access=config_resource_access or {})关键点:
- 更新
config_json时,先在事务内用SELECT ... FOR UPDATE对目标行加锁,读取数据库中的最新配置(而非入参agent.config_json快照),再执行合并,最后提交。 - 这样即使两个进程同时保存,第二个事务也会等待锁释放后读到第一个事务的结果,隐藏引用不会丢失。
- 集成测试 test_agent_config_resource_authorization.py 中构造了并发写入场景(
concurrent_config["context"]["skills"].append(hidden_concurrent)),断言并发保存后skills同时包含configured与hidden_concurrent,即「并发合并使用最新持久配置」。
需要说明的设计取舍:字段补丁与行锁保护的是隐藏引用这类「不可见字段」的并发更新;同一可见字段的并发编辑仍按最后提交的补丁生效,不引入配置版本协议——这是有意为之的简化。
运行时交集:持久配置不改写,生效范围不越权
保存侧保留完整期望选择,安全性由运行侧兜底。normalize_agent_context_config(context.py)在每次运行前计算期望选择 × 当前操作者可访问资源的交集:
- 字段值为
null时:tools/knowledges/mcps/skills展开为当前用户可访问的全部资源;subagents保持全部可访问语义。 - 字段值为显式列表时:用
_normalize_selected_resource_keys过滤掉当前用户无权访问的键,只保留交集。 preload_skills额外限制在归一化后的skills范围内。
这条归一化路径只影响本次运行的有效配置,绝不回写数据库。对应到决策验证表:委托管理员 B 运行时交集为 5,而数据库持久列表仍为 10——「无权资源既不会进入有效配置,持久列表也不收缩」。同时,prepare_agent_runtime_context通过_runtime_prepared标志保证同一 Context 对象再次构图时复用准备结果,避免重复解析。
前端配合:只提交变化字段、清空全部与使用全部
后端语义要成立,前端必须只提交修改过的配置字段,而不是把编辑表单的完整上下文整体 PUT 回去。决策指出相关实现位于 AgentEditModal、配置表单与 store,辅助工具函数见 agentConfigUtils.js:
- 普通列表编辑保留隐藏引用:编辑页对不可见引用不显示也不提交,取消最后一个可见项不会自动变成清空全部。
- 清空全部:用户显式执行清空操作时,前端提交空列表(对应工具的「禁用」策略),后端据此整体移除全部引用(含不可见项)。
- 子智能体:对应操作显示为「使用全部」,提交
null/空列表恢复全部可访问语义;null与空列表在界面上均展示全部可访问项。
保存成功后,前端采用后端返回的合并配置建立基线,保证后续编辑基于真实持久状态,而不是本地残缺快照。前端回归由 agentConfigSave.test.js 与 agentConfigUtils.test.js 覆盖;旧 store 整体提交逻辑在「变动字段断言」处失败,印证了这一改动。
替代方案为何被否决
决策明确排除了三种候选:
| 替代方案 | 否决原因 |
|---|---|
| 仅删除前端过滤 | 无法保护绕过 UI 的直接 API 调用 |
| 仅由前端拼回隐藏引用 | 前端无法拥有访问权限判定与最新持久状态 |
| 为每个资源增加独立增删 API | 扩大协议和维护范围;现有保存接口已能用省略字段、非空列表与显式策略值表达全部操作 |
后果与边界
采纳本决策后的行为边界:
- 不可见的失效引用会继续保留;拥有相应访问权限的管理者可以移除可见选择;显式策略切换(清空全部/使用全部)可以整体清空引用。
- 字段补丁 + 行锁保护并发更新下的最新隐藏引用;可见字段的并发编辑按最后提交的补丁生效,不引入版本协议。
null与空列表的差异继续由各资源字段契约拥有:不统一改变子智能体「空列表 = 全部可访问」的兼容行为。- 运行时归一化不改写持久配置,用户操作参考统一见配置智能体 — 资源选择语义。
验证与回归
验收主张覆盖四条主线,全部 Passed(对应决策文档「验证」章节):
- 完整保存:A 的 10 项选择在 B 只能访问其中 5 项时仍完整保存——真实 HTTP 集成后独立 PostgreSQL 回读验证;负向案例为独立进程恢复旧整体覆盖导致隐藏保留断言失败。
- 可见增删 + 顺序:可见增删保留交错资源顺序,新增无权引用被拒绝(HTTP 422)——由资源合并 unit 测试与创建/更新集成测试验证。
- 并发合并:使用最新持久配置——PostgreSQL 观察实际锁等待后并发提交再回读。
- 运行时交集:B 运行配置只含交集、数据库保留完整期望选择——使用真实数据库用户与资源归一化验证。
后端回归位于 test_agent_repository.py、test_agent_config_service.py、backend/test/unit/toolkits/test_install_skill.py与 test_agent_config_resource_authorization.py;前端回归位于 agentConfigSave.test.js 与 agentConfigUtils.test.js。
验证方法学也值得借鉴:真实 HTTP 集成测试在独立 Compose 槽位完成;最终简化后,在 main 开发环境运行docker compose exec -u 0 -T api uv run --no-sync --group test pytest test/unit -m "not slow" -q -o faulthandler_timeout=30,结果为 1782 passed、50 skipped(使用现有依赖完成验证,未修改依赖锁文件);pnpm run lint:check、pnpm run test:unit(269 passed)与pnpm run build通过,浏览器验证覆盖浅深色及 1440/1024/768/375 像素宽度。需要强调的是,本次没有执行真实 worker E2E,运行资源交集证据止于使用真实身份和数据库的归一化入口,不应表述为完整模型调用验证。
小结
「共享智能体资源选择的保存边界」提供了一套可复制的多租户配置保存范式:保存端用字段补丁 + 权限过滤 + 行锁合并保证持久配置的完整与并发安全,运行端用交集归一化保证每次运行不越权,两端以null/空列表/非空列表三种策略值为契约协同。它同时处理了顺序稳定性(交错引用原序保留)、协议最小化(不引入版本号或独立增删 API)与前端体验(清空全部/使用全部两种显式操作)三个层面,是 Yuxi 中资源权限模型与配置持久化结合的典型实现,相关完整配置字段与运行语义可继续阅读配置智能体与Agent 运行时上下文。
【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考