在本地,用你的语言画一张时序图:Excalidraw与Ollama的私密协作指南

深夜,你刚和同事开完一个技术方案对齐会。白板上画满了潦草的线条和方框,大家七嘴八舌地讨论着“客户端”、“服务端”、“消息队列”之间的交互。会议结束,你回到工位,面对空白的文档,需要把刚才讨论的复杂流程整理成一个清晰的时序图,以便存档和分享。你打开绘图工具,开始拖拽生命线、绘制箭头、调整文本位置……半小时过去了,图还没画完一半,而最初的灵感与讨论的细节,正在这繁琐的重复劳动中逐渐消磨。

这场景是否似曾相识?我们花费大量时间在“画图”这个动作本身,而非思考和设计流程。有没有一种方法,能让我们用最自然的方式——语言——来描述交互,然后自动获得一张可编辑、可分享的图表?更重要的是,如果这个过程完全在本地完成,不依赖任何云端服务,不泄露一丝一毫的内部系统设计细节,那该有多好?

今天,这个想法可以成为你工作流中的标准配置。我们将一起搭建一个完全离线的、由你掌控的“文本到图表”生成器。它的核心是两个强大开源工具的联姻:Excalidraw,那个广受喜爱、风格独特的手绘风白板;以及 Ollama,一个让你能在个人电脑上轻松运行大型语言模型的工具。我们将绕过所有云端API,在你的笔记本或公司内网服务器上,构建一条从自然语言描述到精美时序图的自动化流水线。这不仅仅是提高效率,更是对数据主权和隐私的一次彻底回归。

1. 为什么选择完全本地化的方案?

在深入技术细节之前,我们有必要先厘清一个核心问题:当云端AI服务唾手可得时,为何要“自找麻烦”地在本地部署?答案远不止于“技术控”的偏好,它关乎效率、成本、安全与控制的深层平衡。

云端方案的隐形成本:调用OpenAI或Claude的API生成图表描述,看似简单快捷,但背后隐藏着诸多限制。首先是延迟,网络请求与响应在关键会议中可能成为卡点。其次是成本,频繁调用API用于图表生成,长期累积是一笔不小的开销。但最关键的,是数据隐私与安全。当你将“用户登录时,请求先经过API网关,再由认证服务校验令牌,最后查询用户数据库”这样的内部架构描述发送给第三方服务时,你无法确切知道这些数据会被如何存储、处理或潜在利用。对于金融、医疗或任何处理敏感信息的行业,这构成了不可接受的风险。

相比之下,本地化方案提供了截然不同的价值主张:

  • 绝对的数据隐私:所有数据处理、模型推理均在本地设备或内网服务器完成,原始文本描述和生成的中间数据从未离开你的可控环境。
  • 零网络依赖与极低延迟:无论是在飞机上、客户现场没有外网的环境,还是公司内网隔离区,你都能无缝使用。模型推理在本地CPU/GPU上进行,响应速度极快。
  • 完全的可定制性与控制:你可以自由选择最适合你场景的模型(比如更擅长理解技术流程的Code Llama),调整模型参数,甚至针对你公司的特定术语(如内部服务名“宙斯”、“阿波罗”)对模型进行微调,使其理解力远超通用模型。
  • 一次部署,无限使用:模型下载到本地后,除了电费,几乎没有后续边际成本。你可以尽情实验、生成,无需担心账单暴涨。

Ollama的出现,极大地降低了本地运行大模型的门槛。它像一个模型管理器和运行时容器,让你通过一行命令就能拉取并运行Llama 3、Mistral、Gemma等主流开源模型。而Excalidraw,以其简洁的JSON数据结构和友好的API,成为了将模型输出的结构化信息“画”出来的绝佳画布。两者的结合,创造了一个安全、高效、且充满乐趣的自动化工具链。

2. 搭建你的本地AI绘图工坊:环境准备与模型选型

让我们开始动手。整个过程可以清晰地分为三个层次:本地模型服务层逻辑处理与桥接层、以及前端展示与交互层。我们将自底向上,一步步构建。

2.1 第一步:让Ollama在你的机器上安家

Ollama的安装过程简单到令人惊讶。它支持macOS、Linux和Windows(通过WSL2)。

对于macOS和Linux用户,打开终端,执行以下命令即可:

curl -fsSL https://ollama.ai/install.sh | sh

安装完成后,运行 ollama serve 来启动服务。它会默认在 11434 端口启动一个本地API服务。

现在,你需要选择一个模型。对于“文本描述转时序图”这个任务,模型需要具备良好的自然语言理解能力结构化输出能力。以下是几个经过验证的推荐选择:

模型名称 参数量 特点与适用场景 启动命令示例
Llama 3 8B / 70B Meta最新开源模型,通用能力强,指令跟随和代码理解出色,8B版本在消费级GPU上运行流畅。 ollama run llama3:8b
Mistral 7B 以“小体积,大智慧”著称,在常识推理和代码任务上表现优异,对硬件要求友好。 ollama run mistral
Code Llama 7B / 13B 专为代码相关任务微调,对于理解“客户端调用服务端”这类技术流程描述有先天优势。 ollama run codellama:7b
Gemma 2B / 7B Google出品,轻量且高效,2B版本甚至在无GPU的笔记本上也能快速响应,适合作为入门尝试。 ollama run gemma:2b

提示:首次运行 ollama run <模型名> 时会自动下载模型,请确保网络通畅。对于公司内网环境,可以考虑在一台有外网的机器上拉取模型后,再通过Ollama的模型导出/导入功能进行分发。

我个人的建议是从 llama3:8bmistral 开始。它们在小参数量模型中取得了很好的平衡。运行后,你可以直接在终端与它对话测试:

>>> 请将“用户点击登录按钮,前端发送请求到认证服务”这句话分解成发送方、接收方和动作。

如果模型能给出结构化的回答,说明它已准备就绪。接下来,我们需要让它为Excalidraw服务。

2.2 第二步:构建桥梁——一个简单的API服务

Ollama提供了与OpenAI API兼容的接口,这大大简化了我们的工作。我们需要创建一个简单的后端服务(可以用Python + FastAPI, Node.js + Express等),它主要做三件事:

  1. 接收前端发送的文本描述。
  2. 调用本地的Ollama API,通过精心设计的Prompt让模型理解并结构化描述。
  3. 将结构化的数据转换成Excalidraw能识别的元素数组(JSON格式),返回给前端。

这里是一个使用Python和FastAPI的极简示例,展示核心逻辑:

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests
import json

app = FastAPI()

# 定义请求体结构
class DiagramRequest(BaseModel):
    description: str

# Ollama API地址 (默认本地11434端口)
OLLAMA_API_URL = "http://localhost:11434/api/generate"

@app.post("/generate-diagram")
async def generate_diagram(request: DiagramRequest):
    # 1. 构建给模型的Prompt,这是成功的关键!
    prompt = f"""
    你是一个将自然语言流程描述转换为时序图组件分析专家。
    请分析以下描述,识别出所有参与者(如用户、客户端、服务器、数据库等)以及它们之间按时间顺序发生的消息传递。
    以严格的JSON格式输出,包含两个字段:
    1. "participants": 一个参与者名称的列表。
    2. "messages": 一个字典列表,每个字典包含 "from"(发送方)、 "to"(接收方)、 "action"(动作描述)字段。

    描述:{request.description}

    输出示例:
    {{
      "participants": ["用户", "前端", "认证服务"],
      "messages": [
        {{"from": "用户", "to": "前端", "action": "点击登录按钮"}},
        {{"from": "前端", "to": "认证服务", "action": "发送用户名和密码"}}
      ]
    }}
    只输出JSON,不要有其他任何解释。
    """

    # 2. 调用本地Ollama服务
    payload = {
        "model": "llama3:8b", # 替换成你运行的模型名
        "prompt": prompt,
        "stream": False,
        "format": "json" # 强烈要求模型以JSON格式回复
    }

    try:
        response = requests.post(OLLAMA_API_URL, json=payload, timeout=60)
        response.raise_for_status()
        result = response.json()
        model_output = result["response"]

        # 3. 解析模型输出的JSON
        diagram_data = json.loads(model_output.strip())

        # 4. 将分析结果转换为Excalidraw元素(核心转换逻辑)
        excalidraw_elements = convert_to_excalidraw(diagram_data)

        return {"elements": excalidraw_elements}

    except requests.exceptions.RequestException as e:
        raise HTTPException(status_code=500, detail=f"调用Ollama服务失败: {e}")
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=500, detail=f"解析模型输出失败: {e}")

def convert_to_excalidraw(diagram_data):
    """将参与者与消息列表转换为Excalidraw元素数组"""
    elements = []
    participants = diagram_data.get("participants", [])
    messages = diagram_data.get("messages", [])

    # 布局参数
    start_x = 100
    start_y = 100
    participant_spacing = 200
    lifeline_length = len(messages) * 60 + 100

    # 1. 绘制参与者(生命线顶部的标签)
    for i, actor in enumerate(participants):
        elements.append({
            "type": "text",
            "x": start_x + i * participant_spacing,
            "y": start_y,
            "text": actor,
            "fontSize": 20,
            "fontFamily": 1, # Excalidraw的手绘字体
            "id": f"actor_{i}"
        })
        # 可选:为每个参与者画一条垂直的生命线(虚线)
        elements.append({
            "type": "line",
            "x": start_x + i * participant_spacing + 40, # 粗略居中
            "y": start_y + 40,
            "points": [[0,0], [0, lifeline_length]],
            "strokeStyle": "dashed",
            "strokeWidth": 2,
            "id": f"lifeline_{i}"
        })

    # 2. 绘制消息箭头
    current_y = start_y + 80
    for msg in messages:
        try:
            from_idx = participants.index(msg["from"])
            to_idx = participants.index(msg["to"])
        except ValueError:
            # 如果参与者不在列表中,跳过或处理错误
            continue

        from_x = start_x + from_idx * participant_spacing + 40
        to_x = start_x + to_idx * participant_spacing + 40

        # 绘制箭头
        elements.append({
            "type": "arrow",
            "x": from_x,
            "y": current_y,
            "points": [[0, 0], [to_x - from_x, 0]],
            "startArrowhead": None,
            "endArrowhead": "arrow",
            "strokeWidth": 2
        })

        # 在箭头上方或下方添加消息文本
        text_x = min(from_x, to_x) + abs(to_x - from_x) / 2 - 30 # 粗略居中
        elements.append({
            "type": "text",
            "x": text_x,
            "y": current_y - 20,
            "text": msg["action"],
            "fontSize": 16,
            "fontFamily": 1
        })

        current_y += 60 # 每条消息下移一定距离

    return elements

运行这个服务 (uvicorn main:app --reload),你就拥有了一个本地API端点 http://localhost:8000/generate-diagram。它接收一段文本,并返回Excalidraw可以直接渲染的元素数组。

3. 驯服模型:Prompt工程与解析优化

模型的表现,九成取决于你如何与它对话。一个模糊的指令会得到混乱的结果,而一个清晰、结构化、带有示例的Prompt,则能引导模型成为得力的助手。我们的目标是让模型成为一个“时序图分析师”。

3.1 设计一个强健的Prompt

上面的代码中已经包含了一个基础Prompt,但我们可以让它更强大。一个优秀的Prompt通常包含以下几个部分:

  1. 角色设定:明确告诉模型它要扮演什么角色。
  2. 任务描述:清晰、无歧义地说明需要它做什么。
  3. 输入输出格式规范:这是关键!必须明确要求模型以特定格式(如JSON)输出,并给出精确的字段定义和示例。
  4. 约束条件:告诉模型不要做什么,比如“只输出JSON,不要有任何额外解释”。
  5. 上下文或示例(可选但推荐):提供一个或多个例子,让模型更好地理解你的期望。

让我们优化一下之前的Prompt:

你是一个软件架构师,专门负责将自然语言描述的业务流程转换为精确的时序图组件定义。

你的任务:
分析用户提供的流程描述,识别出所有参与交互的实体(参与者)以及它们之间按严格时间顺序交换的消息。

请遵循以下规则:
- 参与者:通常是系统、服务、用户角色或外部组件(如“用户”、“前端APP”、“订单服务”、“支付网关”、“数据库”)。将它们提取为一个列表。
- 消息:每个消息必须明确有发送方(from)、接收方(to)和具体的动作描述(action)。动作描述应简洁,使用动词开头(如“发送请求”、“返回结果”、“查询数据”)。
- 忽略描述中的非功能性语句(如“很快地”、“然后系统会”),只关注核心交互。
- 如果描述中存在条件判断(如“如果验证失败”),将其视为一条独立的消息,动作描述中可包含条件。

你必须以以下JSON格式输出,且只输出此JSON对象:
{
  "participants": ["参与者A", "参与者B", ...],
  "messages": [
    {"from": "发送方1", "to": "接收方1", "action": "动作描述1"},
    {"from": "发送方2", "to": "接收方2", "action": "动作描述2"}
  ]
}

示例1:
输入:“用户在前端提交表单,前端调用API服务,服务再写入数据库。”
输出:
{
  "participants": ["用户", "前端", "API服务", "数据库"],
  "messages": [
    {"from": "用户", "to": "前端", "action": "提交表单"},
    {"from": "前端", "to": "API服务", "action": "调用API"},
    {"from": "API服务", "to": "数据库", "action": "写入数据"}
  ]
}

示例2:
输入:“客户端发起登录,认证服务检查密码,若正确则返回令牌,否则返回错误。”
输出:
{
  "participants": ["客户端", "认证服务"],
  "messages": [
    {"from": "客户端", "to": "认证服务", "action": "发起登录请求"},
    {"from": "认证服务", "to": "客户端", "action": "返回令牌(密码正确)"},
    {"from": "认证服务", "to": "客户端", "action": "返回错误信息(密码错误)"}
  ]
}

现在,请处理以下描述:
{用户输入的具体描述}

这个Prompt更详细,提供了更明确的规则和多个示例,能极大提高模型输出结构的准确性和稳定性。

3.2 处理模型的“不听话”与后处理校验

即使有最好的Prompt,模型也可能出错。常见的错误包括:

  • 输出非纯JSON:模型可能在JSON前后添加解释性文字。
  • 参与者识别不全或重复
  • 消息顺序错乱

因此,在我们的后端服务中,后处理校验环节必不可少。在 convert_to_excalidraw 函数之前,可以增加一个校验模块:

def validate_and_clean_diagram_data(raw_data: dict) -> dict:
    """校验并清理从模型获得的图表数据"""
    cleaned = {"participants": [], "messages": []}

    # 1. 确保participants是列表且去重
    if "participants" in raw_data and isinstance(raw_data["participants"], list):
        # 使用集合去重,同时保持原始顺序(Python 3.7+ dict保持插入顺序)
        seen = set()
        cleaned["participants"] = [p for p in raw_data["participants"] if not (p in seen or seen.add(p))]

    # 2. 清理messages
    if "messages" in raw_data and isinstance(raw_data["messages"], list):
        for msg in raw_data["messages"]:
            if isinstance(msg, dict) and all(k in msg for k in ("from", "to", "action")):
                # 确保消息的发送方和接收方都在参与者列表中,如果不在,则自动添加
                for role in [msg["from"], msg["to"]]:
                    if role not in cleaned["participants"]:
                        cleaned["participants"].append(role)
                cleaned["messages"].append(msg)
            else:
                # 记录日志,跳过格式错误的消息
                print(f"警告:跳过格式错误的消息: {msg}")

    # 3. 如果参与者或消息为空,抛出错误
    if not cleaned["participants"] or not cleaned["messages"]:
        raise ValueError("无法从描述中提取有效的参与者或消息。请尝试更清晰的描述。")

    return cleaned

将这个函数插入到解析模型输出之后、转换Excalidraw元素之前,可以确保数据的清洁和有效,避免前端渲染时崩溃。

4. 前端集成:让Excalidraw动起来

最后一步,我们需要一个简单的前端界面,让用户输入文本,调用我们的本地API,并将结果渲染到Excalidraw画布上。我们可以利用Excalidraw提供的React组件或Vanilla JS API轻松实现。

这里提供一个使用原生JavaScript和Excalidraw嵌入的极简示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>本地AI时序图生成器</title>
    <script src="https://unpkg.com/@excalidraw/excalidraw@latest/dist/excalidraw.production.min.js"></script>
    <style>
        body { font-family: sans-serif; margin: 20px; }
        .container { display: flex; height: 90vh; gap: 20px; }
        .input-panel { flex: 1; display: flex; flex-direction: column; }
        textarea { flex: 1; padding: 15px; font-size: 16px; border: 2px solid #ccc; border-radius: 8px; resize: none; }
        button { margin-top: 10px; padding: 15px; font-size: 18px; background: #4CAF50; color: white; border: none; border-radius: 8px; cursor: pointer; }
        button:disabled { background: #cccccc; }
        .excalidraw-container { flex: 2; border: 2px solid #eee; border-radius: 8px; }
    </style>
</head>
<body>
    <h1>🔒 本地AI时序图生成器</h1>
    <p>描述你的流程,一键生成可编辑的时序图。所有处理均在您的电脑本地完成。</p>
    <div class="container">
        <div class="input-panel">
            <textarea id="descriptionInput" placeholder="例如:用户点击购买按钮,前端调用订单服务创建订单,订单服务调用库存服务扣减库存,最后调用支付服务发起收款。">
            </textarea>
            <button id="generateBtn" onclick="generateDiagram()">生成时序图</button>
            <div id="status"></div>
        </div>
        <div class="excalidraw-container" id="excalidraw"></div>
    </div>

    <script>
        const { Excalidraw } = window.ExcalidrawLib;
        let excalidrawAPI = null;

        // 初始化Excalidraw
        const excalidrawElement = document.getElementById("excalidraw");
        excalidrawAPI = new Excalidraw.Excalidraw({
            target: excalidrawElement,
            props: {
                // 可以在这里配置Excalidraw的初始属性,如主题、UI等
                viewModeEnabled: false,
                zenModeEnabled: false,
                gridModeEnabled: false,
            }
        });

        async function generateDiagram() {
            const description = document.getElementById('descriptionInput').value.trim();
            const button = document.getElementById('generateBtn');
            const statusDiv = document.getElementById('status');

            if (!description) {
                statusDiv.textContent = "请输入流程描述。";
                statusDiv.style.color = 'red';
                return;
            }

            button.disabled = true;
            button.textContent = '生成中...';
            statusDiv.textContent = '正在调用本地AI模型分析...';
            statusDiv.style.color = 'blue';

            try {
                // 调用我们本地部署的后端API
                const response = await fetch('http://localhost:8000/generate-diagram', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ description: description })
                });

                if (!response.ok) {
                    throw new Error(`API请求失败: ${response.status}`);
                }

                const result = await response.json();
                statusDiv.textContent = '解析成功,正在渲染图表...';

                // 将获取到的元素更新到Excalidraw画布
                // 注意:这里我们直接替换整个场景。更优雅的做法是增量更新。
                excalidrawAPI.updateScene({
                    elements: result.elements,
                    appState: {
                        viewBackgroundColor: '#fafafa', // 设置一个干净的背景色
                    },
                });

                statusDiv.textContent = '✅ 时序图已生成!您可以在右侧画布上直接拖动、编辑。';
                statusDiv.style.color = 'green';

            } catch (error) {
                console.error('生成失败:', error);
                statusDiv.textContent = `生成失败: ${error.message}`;
                statusDiv.style.color = 'red';
            } finally {
                button.disabled = false;
                button.textContent = '生成时序图';
            }
        }
    </script>
</body>
</html>

将这个HTML文件保存,并用浏览器打开(可能需要解决CORS问题,在生产环境中可通过后端代理或配置解决)。现在,你拥有了一个完全在本地运行的、从文本到可编辑时序图的一站式工具。

5. 超越时序图:扩展你的本地AI绘图能力

一旦核心管道打通,你会发现,这套本地化“文本转图表”的范式拥有巨大的扩展潜力。它不仅仅局限于时序图。

流程图(Flowchart):Prompt可以调整为识别“开始”、“结束”、“判断”、“流程”等关键词,并生成包含菱形决策框、流程框的图表结构。Excalidraw的图形库完全支持这些元素。

架构图(C4 Model或简单框图):你可以训练模型识别“微服务”、“数据库”、“消息队列”、“负载均衡器”等组件,并按照一定的布局规范(如分层布局)将它们排列出来,并用箭头表示依赖关系。

状态机图(State Diagram):从“订单从待支付状态,经过支付成功事件,转移到已完成状态”这样的描述中,提取状态和转移事件,生成状态节点和转移箭头。

关键在于设计针对特定图表类型的Prompt编写对应的转换函数。例如,对于流程图,你的转换函数 convert_to_excalidraw 需要知道如何绘制菱形(type: "diamond")和不同的箭头样式。

更进一步,你可以将这个工具集成到你的日常开发环境中:

  • VS Code插件:在编辑Markdown或文档时,选中一段描述,右键一键生成图表并插入。
  • 命令行工具(CLI):通过终端命令 draw-diagram “用户登录流程...”,自动生成图表并保存为图片或 .excalidraw 文件。
  • CI/CD流水线:从代码注释或API定义文件中自动生成架构图或序列图,并嵌入到自动生成的文档站点中。

我自己的使用习惯是,在编写技术设计文档时,会专门用一段注释 <!-- DIAGRAM: 客户端请求网关... --> 来描述关键流程。然后运行一个简单的脚本,批量处理文档中的所有此类注释,自动替换成生成的图表图片或可交互的Excalidraw链接。这确保了文档中的图表永远与最新的设计文字描述同步,实现了真正的“图文同源”。

搭建这样一套系统初看有些步骤,但一旦跑通,它带来的效率提升和心智负担的减轻是巨大的。你不再需要在外网、内网、安全性、付费之间纠结。你的描述语言就是最强大的绘图工具,而这一切,都运行在你完全信任的本地环境里。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐