[T.6] 团队项目:技术规格说明书
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2025年春季软件工程(罗杰、任健) |
| 这个作业的要求在哪里 | [T.6] 团队项目:技术规格说明书 |
| 我在这个课程的目标是 | 学习软件工程的基础知识,和团队成员们实践各种软件工程的方法与流程,开发一个让我们值得骄傲的项目 |
| 这个作业在哪个具体方面帮助我实现目标 | 制定了规格说明书,有助于明确项目需求、指导团队开发、统一开发标准、便于团队协作 |
1.技术栈
后端框架
后端使用 fastapi 做 http 服务框架,sqlalchemy 做 orm 框架,uv 用作环境/包管理工具。
存储服务
- 关系数据库使用 postgresql
- 向量数据库使用 qdrant
- 对象存储服务使用 minio,兼容 aws s3 协议的服务
- (可选)全局 kv 缓存使用 redis
前端框架
前端使用 react 框架,canvas 使用 react-flow 库
2.软件架构
后端主体分为三层,上层依赖于下层提供的接口服务,下层不直接感知上层的应用
暂时无法在飞书文档外展示此内容
- toolkit 层:作为软件底层驱动层,提供全局配置,向量数据库与对象存储操作封装,组件基类和类型标注检查功能;同时配置全局的权限管理器,通过 blanker 信号机制,由 server 层注册 handler 函数进行数据窃取,架构解藕
- core 层:主要提供 component 全局管理器以及 pipeline 实体的解析与执行,将异步生成器包装后透传到 server 进行流式输出
- server 层:传统后端网络服务层。提供 rdb 各实体管理 crud 操作,以及 fastapi 路由导航,中间件注入以及依赖注入等功能。
3.代码规范
- Fastapi 天然支持异步编程模型,所有的 IO 操作(如网络交互/磁盘读写)必须使用异步版本的 sdk,或考虑自己封装,异步库统一使用 asycio 标准库作为运行时
- 代码统一规范由 pre-commit 配置,拉取项目进行开发前,首先要运行
pre-commit install,确保每次 commit 的代码风格统一 - 功能单实例化:对于“连接服务属性”的客户端逻辑代码,考虑封装为 manager 全局单实例组件,如 minio / rdb repository / vdb_client 等
- 命名规范:遵循 python 社区规范,变量 / 函数名采用 snake 命名法,类名使用驼峰命名法
- 。。。
4.功能拆解
4.1组件化工作流实现+自定义插件二次开发
所有组件都必须基于 toolkit 层提供的基类,无论官方组件还是用户自定义组件。当用户编写自定义组件时,可以通过 pip install ragnarok_toolkit 安装,以获得系统底层支持能力以及类型标注。
class RagnarokComponent(ABC):
"""
base class for a Ragnarök component,
designed to standardize component code format
"""
# the description of the component's functionality
DESCRIPTION: str
# whether to enable type annotation check for identification
ENABLE_HINT_CHECK: bool = True
@classmethod
@abstractmethod
def input_options(cls) -> Tuple[ComponentInputTypeOption, ...]:
"""the options of all the input value"""
return ()
@classmethod
@abstractmethod
def output_options(cls) -> Tuple[ComponentOutputTypeOption, ...]:
"""the options of all the output value"""
return ()
@classmethod
@abstractmethod
def execute(cls, *args, **kwargs) -> Dict[str, Any]:
"""
execute the component function, could be either sync or async
"""
pass
@classmethod
def validate(cls) -> bool:
"""check if the 'execute' function corresponds to the INPUT_OPTIONS and OUTPUT_OPTIONS"""
execute_params = get_type_hints(cls.execute)
execute_params.pop("return")
input_options = cls.input_options()
input_option_names = {option["name"] for option in input_options}
# check input name set
if set(execute_params.keys()) != input_option_names:
return False
# check input type
for input_option in input_options:
param_name = input_option["name"]
hint_type = execute_params.get(param_name)
allowed_types = {io_type.python_type for io_type in input_option.get("allowed_types")}
if input_option.get("required"):
if hint_type not in allowed_types:
return False
else:
# optional param
if (
hint_type is not None
and hasattr(hint_type, "__origin__")
and (hint_type.__origin__ is Optional or hint_type.__origin__ is Union)
):
# extract the inner type from Optional
actual_types = {arg for arg in hint_type.__args__ if arg is not type(None)}
if not actual_types.issubset(allowed_types):
return False
else:
return False
# TODO is it possible to validate output value here
return True
def __new__(cls, *args, **kwargs):
raise TypeError(f"Class {cls.__name__} and its subclasses cannot be instantiated.")
组件会通过 manager 自动注册到系统之中。
4.2异步流式/断点运行
Pipeline 作为程序中的数据实体存在,统一封装为 PipelineEntity,组件运行依靠 python 动态配置判断同步或异步版本,进行执行。
组件返回的携程对象应被上层 await 后包装,统一封装为 AsyncGenerator,传输到 server 层进行 sse 流式返回。
class PipelineEntity:
def __init__(self, node_map: Dict[str, PipelineNode], inject_input_mapping: Dict[str, Tuple[str, str]]) -> None:
# store the mapping of the node_id and node entity
self.node_map = node_map
# store the processing result, breaking the contagiousness of multi async generator
self.result_queue: asyncio.Queue[PipelineExecutionInfo] = asyncio.Queue(maxsize=2 * len(self.node_map))
# num of the unfinished node
self.remaining_num = len(node_map)
# outer input inject mapping. eg: inject_name -> (node_id, node_input_name)
self.inject_input_mapping = inject_input_mapping
# beginning nodes, whose input is either empty or totally injected
self.begin_nodes = [
node
for node in node_map.values()
if len(node.component.input_options()) == 0
or {input_option.get("name") for input_option in node.component.input_options()}.issubset(
{
node_input_name
for node_id, node_input_name in inject_input_mapping.values()
if node_id == node.node_id
}
)
]
async def run_async(self, *args, **kwargs) -> AsyncGenerator[PipelineExecutionInfo, None]:
"""execute the pipeline, async version"""
# 1. inject outer input
for inject_name, (node_id, node_input_name) in self.inject_input_mapping.items():
actual_input_value = kwargs.get(inject_name)
# TODO check if actual_input_value is None or not correspond to node expected type
self.node_map[node_id].input_data[node_input_name] = actual_input_value
# 2. run beginning task
for node in self.begin_nodes:
asyncio.create_task(self.run_node_async(node))
# 3. collect result
while self.remaining_num > 0:
execution_info = await self.result_queue.get()
if execution_info.type == "process_info":
self.remaining_num -= 1
yield execution_info
async def run_node_async(self, node: PipelineNode) -> None:
"""run a node execution function, async version"""
# TODO error handling
if asyncio.iscoroutinefunction(node.component.execute):
node_outputs = await node.component.execute(**node.input_data)
else:
node_outputs = node.component.execute(**node.input_data)
# if is output node, yield output info
# HINT!: this have to be set before putting process_info, because we use process_info to count remaining num
if node.output_name is not None:
self.result_queue.put_nowait(
PipelineExecutionInfo(node.node_id, "output_info", {node.output_name: node_outputs})
)
# return current node result
self.result_queue.put_nowait(PipelineExecutionInfo(node.node_id, "process_info", node_outputs))
# trigger forward nodes
tasks = []
for connection in node.forward_node_info:
current_output = node_outputs[connection.from_node_output_name]
trigger_node = self.node_map[connection.to_node_id]
if trigger_node is None:
continue
trigger_node.input_data[connection.to_node_input_name] = current_output
trigger_node.waiting_num -= 1
if trigger_node.waiting_num == 0:
task = asyncio.create_task(self.run_node_async(trigger_node))
tasks.append(task)
await asyncio.gather(*tasks)
@classmethod
def from_json_str(cls, json_str: str) -> "PipelineEntity":
"""instantiate a pipeline entity from a json format string"""
pass
4.3权限管理
知识库权限划分为公有库和私有库,在 toolkit 层由 permission_manager实例统一管理。
通过 blinker 信号机制窃取到 server 层 rdb 的信息,得到权限三元组进行统一鉴权。
4.4业务层
对于业务模块,应当实现如下一些主功能:
- 创建企业
- 创建用户
- 创建知识库
- 权限变更
- 创建/删除/修改/更新 pipeline
- 调用运行 pipeline(区分普通用户前端调用和企业账号 api 调用)
5.出口条件
- 基本功能开发完毕,能跑通 rag 全流程,串联起各个模块
- 对于正常的用户行为能有正确的输出结果。由于是 b 端的应用,对非法操作有一定容忍度
6.测试计划
- 功能开发阶段,对单个模块进行单元测试,项目已配置好 pytest 以及 asyncio 异步版本插件
- 前后端交互环节,主要测试 pipeline 格式编解码
- 整体开发完成后统一进行功能测试,包括前端调试界面以及 api 接口
7.技术风险识别和评估
主要有两个风险点:
- 权限系统的设定:如果设计不佳,会有用户越权的可能性,要在 toolkit 底层接口层提供权限安全的接口,不能暴露隐患api
- 工作流执行阶段,对单节点故障以及非法工作流(如死循环)要有检测识别能力,避免程序跑死

浙公网安备 33010602011771号