第 10 节:实现 Workflow 流程编排

18 阅读8分钟

第 10 节:实现 Workflow 流程编排

阅读时间:约 9 分钟
难度级别:实战
前置知识:Agno Agent、CubeJS 集成

本节概要

通过本节学习,你将掌握:

  • Workflow 的核心概念和设计原则
  • 创建多步骤的 Workflow 流程
  • 实现 Agent 步骤和函数步骤
  • 步骤之间的数据传递和转换
  • Workflow 的流式输出实现
  • 测试和调试 Workflow 的方法

引言

Workflow 是 Agno 框架的核心功能之一,它允许我们将多个步骤组合成一个完整的流程。本节我们将学习如何实现 Text-to-BI 的完整查询流程,从自然语言到最终结果。

Workflow 是将多个步骤组合成完整流程的关键。本文将介绍如何使用 Agno Workflow 实现 Text-to-BI 的完整查询流程。

🎯 本章目标

完成后,你将拥有:

  • ✅ 完整的 Text-to-BI Workflow
  • ✅ 多步骤流程编排
  • ✅ Agent 和 Function 步骤组合
  • ✅ 数据在步骤间传递
  • ✅ 流式和非流式执行

🔄 Workflow 设计

流程分解

用户问题
   ↓
步骤1: CubeJS Agent
   → 生成 CubeJS 查询 JSON
   ↓
步骤2: get_sql_and_execute
   → 获取 SQL
   → 执行查询
   → 返回原始数据
   ↓
步骤3: format_query_results
   → 格式化为 Markdown 表格
   ↓
步骤4: Result Formatter Agent
   → 分析数据
   → 生成洞察
   ↓
完整结果

为什么这样设计?

单一职责:

  • 每个步骤只做一件事
  • 易于测试和调试
  • 可以独立优化

可扩展性:

  • 轻松添加新步骤
  • 可以插入验证步骤
  • 支持条件分支

📝 创建 Workflow

Step 1: 导入依赖

"""
Text-to-BI Workflow
自然语言转SQL查询和执行的流式 Workflow
"""
import json
import re
from typing import Any, Dict, Iterator

from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.models.deepseek import DeepSeek
from agno.run.workflow import WorkflowRunOutput, WorkflowRunOutputEvent
from agno.workflow import Workflow, StepOutput, StepInput

from agents.cubejs_agent import build_cubejs_agent
from services.cubejs_service import CubeJSService

Step 2: 实现辅助函数

提取 JSON 查询:

def extract_json_query(agent_response: str) -> Dict[str, Any]:
    """
    从 Agent 响应中提取 JSON 查询对象
    
    Args:
        agent_response: Agent 的文本响应,包含 JSON 代码块
        
    Returns:
        解析后的查询字典
        
    Raises:
        ValueError: 当无法提取或解析 JSON 时
    """
    # 尝试提取 JSON 代码块
    json_pattern = r'```json\s*([\s\S]*?)\s*```'
    matches = re.findall(json_pattern, agent_response)
    
    if matches:
        try:
            return json.loads(matches[0])
        except json.JSONDecodeError as e:
            raise ValueError(f"JSON 解析失败: {e}")
    
    # 如果没有代码块,尝试直接解析整个响应
    try:
        return json.loads(agent_response)
    except json.JSONDecodeError:
        raise ValueError("无法从响应中提取有效的 JSON 查询")

Step 3: 实现步骤函数

步骤2: 获取 SQL 并执行查询

def get_sql_and_execute(step_input: StepInput) -> StepOutput:
    """
    获取 SQL 并执行查询,返回结果数据
    
    Args:
        step_input: 包含上一步输出的 StepInput 对象
        
    Returns:
        包含查询结果的 StepOutput 对象
    """
    # 从上一步的内容中提取 JSON 查询
    cubejs_query = extract_json_query(
        step_input.get_last_step_content() or ""
    )
    
    service = CubeJSService(base_url="http://localhost:4000")
    
    # 获取 SQL(用于展示)
    try:
        sql_result = service.sql(query=cubejs_query, format="rest")
    except Exception as e:
        sql_result = None
    
    # 执行查询(获取数据)
    results = service.load(query=cubejs_query)
    
    # 格式化 SQL 展示
    sql_content = ""
    if sql_result and "sql" in sql_result:
        sql_info = sql_result["sql"]["sql"]
        sql_statement = sql_info[0]
        
        # 简单格式化 SQL
        sql_statement = sql_statement.replace(' FROM', '\nFROM')
        sql_statement = sql_statement.replace(' WHERE', '\nWHERE')
        sql_statement = sql_statement.replace(' GROUP BY', '\nGROUP BY')
        
        sql_content = f"""

**SQL Query**

```sql
{sql_statement}

"""

# 返回 SQL 展示 + 查询结果数据(隐藏在 HTML 注释中)
return StepOutput(
    content=sql_content + "\n<!-- DATA:" + json.dumps(results) + " -->"
)

**步骤3: 格式化查询结果**

```python
def format_query_results(step_input: StepInput) -> StepOutput:
    """
    格式化查询结果为美观的 Markdown 格式
    
    Args:
        step_input: 包含上一步输出的 StepInput 对象
        
    Returns:
        包含格式化结果的 StepOutput 对象
    """
    # 从上一步的内容中提取隐藏的数据
    last_content = step_input.get_last_step_content() or ""
    
    # 提取 HTML 注释中的 JSON 数据
    data_match = re.search(r'<!-- DATA:(.*?) -->', last_content, re.DOTALL)
    
    if not data_match:
        return StepOutput(content="**查询结果**\n\n无法获取查询结果")
    
    # 解析 JSON 结果
    results = json.loads(data_match.group(1))
    data = results.get("data", [])
    
    if not data:
        return StepOutput(
            content="**查询结果**\n\n查询执行成功,但没有找到匹配的数据"
        )
    
    # 获取列名和数据统计
    columns = list(data[0].keys())
    total_count = len(data)
    display_count = min(total_count, 20)  # 限制显示20行
    
    # 构建 Markdown 表格
    header = "| " + " | ".join(columns) + " |"
    separator = "| " + " | ".join(["---" for _ in columns]) + " |"
    
    rows = []
    for row in data[:display_count]:
        row_values = []
        for col in columns:
            value = row.get(col, "")
            # 格式化数值
            if isinstance(value, float):
                value = f"{value:.2f}" if not value.is_integer() else str(int(value))
            elif isinstance(value, int) and value >= 1000:
                value = f"{value:,}"
            row_values.append(str(value))
        rows.append("| " + " | ".join(row_values) + " |")
    
    table = "\n".join([header, separator] + rows)
    
    # 构建结果展示
    content_parts = [
        "",
        f"**查询结果** ({total_count:,} 条记录)",
        "",
        table,
        ""
    ]
    
    if display_count < total_count:
        content_parts.append(
            f"*显示前 {display_count} 条,共 {total_count:,} 条记录*"
        )
        content_parts.append("")
    
    return StepOutput(content="\n".join(content_parts))

Step 4: 创建结果分析 Agent

# 创建分析 Agent
result_formatter = Agent(
    name="ResultFormatter",
    model=DeepSeek(id="deepseek-chat"),
    instructions="""You are a data analyst. Provide ultra-concise insights.

**Format:**

**分析**

[2-3 bullet points with key findings, each under 15 words]

**建议**: [One actionable recommendation in 1 sentence]

**Rules:**
- Maximum 60 words total
- Use simple, direct language
- Focus on the most important insight only
- Be specific with numbers""",
    markdown=True,
)

Step 5: 定义 Workflow

# 定义主 Workflow
text_to_bi_workflow = Workflow(
    name="TextToBIWorkflow",
    steps=[
        build_cubejs_agent(),    # 步骤1: 生成 CubeJS 查询
        get_sql_and_execute,     # 步骤2: 获取 SQL 并执行查询
        format_query_results,    # 步骤3: 格式化结果
        result_formatter,        # 步骤4: 分析结果
    ],
    db=SqliteDb(
        db_file="tmp/workflows.db"
    ),
)

🔄 数据流转

步骤间数据传递

# 步骤1输出(Agent)
"""
## 🔍 查询分析
统计员工总数

```json
{
  "measures": ["employees.total_employees"]
}

"""

步骤2输入

step_input.get_last_step_content() # 获取步骤1的输出

步骤2输出

""" SQL Query

SELECT COUNT(*) FROM employees

"""

步骤3输入

step_input.get_last_step_content() # 获取步骤2的输出

步骤3输出

""" 查询结果 (1 条记录)

employees.total_employees
300
"""

步骤4输入(Agent 自动获取)

步骤4输出

""" 分析

  • 公司共有300名员工
  • 这是一个中等规模的团队

建议: 考虑建立更详细的部门统计 """


## 🎮 运行 Workflow

### 方式1: 流式执行

```python
def run_text_to_bi_workflow(
    user_question: str,
    stream: bool = True
) -> Iterator[WorkflowRunOutputEvent] | WorkflowRunOutput:
    """
    运行 Text-to-BI Workflow
    
    Args:
        user_question: 用户的自然语言问题
        stream: 是否使用流式输出
        
    Returns:
        流式输出的事件迭代器或完整的响应对象
    """
    if stream:
        # 流式执行
        return text_to_bi_workflow.run(input=user_question, stream=True)
    else:
        # 非流式执行
        response = text_to_bi_workflow.run(input=user_question, stream=False)
        return response

方式2: 便捷打印函数

def print_text_to_bi_workflow(user_question: str):
    """
    运行 workflow 并直接打印流式输出到控制台
    
    Args:
        user_question: 用户的自然语言问题
    """
    print(f"\n{'='*60}")
    print(f"Question: {user_question}")
    print(f"{'='*60}\n")
    
    text_to_bi_workflow.print_response(
        input=user_question, 
        stream=True, 
        markdown=True
    )

测试 Workflow

if __name__ == "__main__":
    from dotenv import load_dotenv
    load_dotenv()
    
    # 测试示例
    print_text_to_bi_workflow("Show me the total number of employees")
    print("\n")
    print_text_to_bi_workflow("Show male and female employee counts")

运行测试:

cd backend
python workflows/text_to_bi.py

🧪 调试 Workflow

启用调试模式

text_to_bi_workflow = Workflow(
    name="TextToBIWorkflow",
    steps=[...],
    db=SqliteDb(db_file="tmp/workflows.db"),
    debug_mode=True  # 启用调试
)

查看步骤输出

# 非流式执行,查看每个步骤的输出
response = text_to_bi_workflow.run(
    input="统计员工总数", 
    stream=False
)

# 查看所有步骤
for i, step in enumerate(response.steps):
    print(f"\n=== 步骤 {i+1} ===")
    print(f"类型: {step.step_type}")
    print(f"输出: {step.content[:200]}...")

单独测试步骤

# 测试步骤2
from agno.workflow import StepInput, StepOutput

# 模拟步骤1的输出
mock_input = StepInput(content='''
## 🔍 查询分析
统计员工总数

```json
{
  "measures": ["employees.total_employees"]
}

''')

测试步骤2

result = get_sql_and_execute(mock_input) print(result.content)


## 💡 Vibe Coding 要点

### 1. 逐步构建

第1版:只有 Agent 步骤 第2版:添加查询执行 第3版:添加结果格式化 第4版:添加数据分析 第5版:优化和完善


### 2. 测试每个步骤

```python
# 单独测试每个函数
def test_extract_json():
    response = '```json\n{"measures": ["test"]}\n```'
    result = extract_json_query(response)
    assert result == {"measures": ["test"]}

def test_format_results():
    # 测试格式化函数
    ...

3. 处理边界情况

# 空结果
if not data:
    return StepOutput(content="无数据")

# 大数据集
display_count = min(total_count, 20)

# 错误处理
try:
    sql_result = service.sql(query)
except Exception as e:
    sql_result = None  # 继续执行

本节小结

本节我们完成了 Workflow 流程编排:

  1. Workflow 概念:理解了 Workflow、Step、StepInput、StepOutput 等核心概念
  2. 步骤设计:创建了 4 个步骤的完整查询流程
  3. Agent 步骤:使用 CubeJS Agent 生成查询 JSON
  4. 函数步骤:实现了 SQL 执行、结果格式化等函数步骤
  5. 数据传递:掌握了步骤之间的数据传递和转换
  6. 流式输出:实现了 Workflow 的流式执行
  7. 测试验证:通过多种方式验证 Workflow 功能

现在我们有了一个完整的查询流程,可以将其集成到 FastAPI 中。

思考与练习

思考题

  1. 为什么要使用 Workflow 而不是直接调用函数?Workflow 的优势是什么?
  2. 如何决定一个步骤应该是 Agent 还是函数?
  3. 如果某个步骤执行失败,Workflow 应该如何处理?
  4. 如何优化 Workflow 的执行性能?

实践练习

  1. 添加新步骤

    • 在 Workflow 中添加一个数据验证步骤
    • 验证查询结果的合理性
    • 对异常数据进行标记
  2. 错误处理

    • 为每个步骤添加 try-except
    • 实现步骤失败时的回退逻辑
    • 记录详细的错误日志
  3. 并行执行

    • 研究如何实现步骤的并行执行
    • 对比串行和并行的性能差异
    • 分析哪些步骤适合并行
  4. Workflow 可视化

    • 创建一个工具可视化 Workflow 流程
    • 显示每个步骤的执行状态
    • 显示步骤之间的数据流

上一节第 9 节:使用 Agno 构建 AI Agent
下一节第 11 节:流式响应与实时交互