11|工作流框架:Ling的设计思想与整体架构

你好,我是月影。

前面两节课我们讲了AI应用的用户体验中,非常核心的一个技术挑战,即如何在流式输出下,实时处理JSON格式的数据。

我采用的思路是自己实现JSON解析器,使用动态解析JSON数据流的方式,用jsonuri来动态构建JSON数据,这个方案构成处理整个AI业务工作流的核心。

基于这个核心,我封装了一个开源框架叫做 Ling,用来方便地处理复杂的AI工作流。

由于在后续的AI应用实践课程中,我们会比较频繁地采用这个框架,因此在这一节课里,我先来介绍一下这个框架的设计思想与整体架构。

Ling基础架构

Ling是一个基于流式JSON数据的异步工作流框架。它能够比较方便地管理AI业务工作流的节点,并及时处理其中流转的数据。

图片

如上图所示,业务中三个AI节点分别是BotA、BotB、BotC,当BotA推理生成数据时,Ling通过string-response事件在指定字段的数据输出完成时,及时分发给BotB和BotC进行处理。

在这个过程中,所有需要发送给客户端的数据,会通过单一的Stream实例完成数据的分发。

构建 Ling工作流

这么说呢,还是比较抽象,我们还是看一个实际的例子。

让我们来实现一个简易的儿童版AI十万个为什么,用户输入一个问题,根据问题生成一篇适合儿童阅读的、内容稍微丰富一些的文章,效果如下:

图片

我们看到,当我们输入一个问题“天空为什么是蓝色的”时,AI能够非常快速地把内容实时生成出来。

实际上,这个是通过一个简单的工作流来实现的。我们用了两级AI节点,外层是一个大模型负责撰写大纲,内层是一组大模型,将大纲每一章节展开撰写。

具体如何做呢?你可以跟着我一起实际操作。

我们首先用Trae创建一个Vue项目。

然后安装依赖:

pnpm i @bearbobo/ling jsonuri

这样就安装了Ling框架以及客户端依赖的jsonuri。

接着我们配置 .env.local

VITE_API_KEY=sk-qi2oJBNF**********z8txbp4
VITE_END_POINT=https://api.moonshot.cn/v1/chat/completions

然后我们添加 server.js ,内容如下:

import * as dotenv from 'dotenv'
import express from 'express';
import { Ling } from "@bearbobo/ling";
import { pipeline } from 'node:stream/promises';

dotenv.config({
    path: ['.env.local', '.env']
})

const apiKey = process.env.VITE_API_KEY;
const endpoint = process.env.VITE_END_POINT;

const app = express();
const port = 3000;

const outlinePrompt = `
根据用户要求,生成科普文章大纲。

输出以下JSON格式内容:

{
    "title": "文章标题",
    "outline": [
        {
            "section": 1,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 2,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 3,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 4,
            "title": "总结",
            "subtopics": "子主题1\n子主题2",
            "overview": "章节概述"
        },
    ]
}
`;

const contentPrompt = `
根据用户发送的文章标题和概述,撰写详细文章内容。

要求: 
文章的读者是6-8岁的儿童。
文章的风格要符合儿童的阅读习惯,避免使用过于复杂的句子结构和词汇。
文章的内容要围绕用户发送的文章标题和概述进行,不要偏离主题。
限制篇幅,不要超过3个自然段落,纯文本输出,不要加任何Markdown标签。
`;

// SSE 端点
app.get('/stream', async (req, res) => {
    const question = req.query.question;

    const config = {
        model_name: 'moonshot-v1-8k',
        api_key: apiKey,
        endpoint: endpoint,
        sse: true,
    };

    // ------- The work flow start --------
    const ling = new Ling(config);

    const outlineBot = ling.createBot();
    outlineBot.addPrompt(outlinePrompt);

    outlineBot.chat(question);

    outlineBot.on('object-response', ({ uri, delta }) => {
        const matches = uri.match(/outline\/(\d+)/);
        if (matches) {
            const section = matches[1];
            console.log(uri, delta);
            const contentBot = ling.createBot(`content/${section}`, {}, {
                response_format: { type: "text" },
            });
            contentBot.addPrompt(contentPrompt);
            contentBot.chat(`
# 主题
${delta.title}

## 子主题
${delta.subtopics}

## 摘要
${delta.overview}
            `);
        }
    });

    ling.close();

    // setting below headers for Streaming the data
    res.writeHead(200, {
        'Content-Type': "text/event-stream",
        'Cache-Control': "no-cache",
        'Connection': "keep-alive"
    });

    pipeline(ling.stream, res);
});

// 启动服务器
app.listen(port, () => {
    console.log(`Server running on http://localhost:${port}`);
});

我们梳理一下代码逻辑。

首先刚才说有两级大模型,它们的系统提示词分别如下:

const outlinePrompt = `
根据用户要求,生成科普文章大纲。

输出以下JSON格式内容:

{
    "title": "文章标题",
    "outline": [
        {
            "section": 1,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 2,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 3,
            "title": "章节标题",
            "subtopics": "子主题1\n子主题2\n子主题3",
            "overview": "章节概述"
        },
        {
            "section": 4,
            "title": "总结",
            "subtopics": "子主题1\n子主题2",
            "overview": "章节概述"
        },
    ]
}
`;

const contentPrompt = `
根据用户发送的文章标题和概述,撰写详细文章内容。

要求: 
文章的读者是6-8岁的儿童。
文章的风格要符合儿童的阅读习惯,避免使用过于复杂的句子结构和词汇。
文章的内容要围绕用户发送的文章标题和概述进行,不要偏离主题。
限制篇幅,不要超过3个自然段落,纯文本输出,不要加任何Markdown标题。
`;

接着呢,我们用配置创建一个Ling实例,然后创建一个outlineBot,负责大纲的撰写。

const question = req.query.question;

const config = {
    model_name: 'moonshot-v1-8k',
    api_key: apiKey,
    endpoint: endpoint,
    sse: true,
};

// ------- The work flow start --------
const ling = new Ling(config);

const outlineBot = ling.createBot();
outlineBot.addPrompt(outlinePrompt);

outlineBot.chat(question);

使用Ling创建工作流非常简单,我们直接用配置创建Ling的实例对象,然后调用该对象的createBot方法,就可以创建出一个由Ling托管的大模型节点对象。

注意这里的Bot对象,可以用传给createBot独立的api_key、endpoint和model_name参数的方式创建出不同的大模型对象,但是不传的话,则默认使用从Ling实例继承的配置,这里我们配置的是Kimi大模型。

接着我们通过addPrompt方法将outlinePrompt传入,这个方法默认支持nunjucks模板,所以它的第二个参数可以传一个对象,不过我们这个例子没有用到动态参数。

接着我们调用chat方法,传入文本就可以了。

注意Ling默认做了OpenAI和Coze接口的兼容,所以我们创建Ling时,model_name、apk_key和endpoint可以传DeepSeek、Kimi(moonshot)或豆包,以及其他任何兼容OpenAI的大模型。当我们使用豆包时,model_name要传botId。

接下来,我们需要监听outlineBot的 object-response 事件:

outlineBot.on('object-response', ({ uri, delta }) => {
    const matches = uri.match(/outline\/(\d+)/);
    if (matches) {
        const section = matches[1];
        const contentBot = ling.createBot(`content/${section}`, {}, {
            response_format: { type: "text" },
        });
        contentBot.addPrompt(contentPrompt);
        contentBot.chat(`
# 主题
${delta.title}

## 子主题
${delta.subtopics}

## 摘要
${delta.overview}
        `);
    }
});

该事件在JSON解析完成某个数组或对象属性时被自动触发。

除了 object-response 外,Ling还支持string-responseinference-done 事件,前者在解析完成某个字符串属性时被触发,相当于前面课程JSONParser的 string-resolve 事件;后者在整个Bot推理完成时触发。

因为我们希望在文章每个小节完成提纲生成时,就可以立即发给生成正文的Bot处理。所以这里使用了object-response 事件,确保它第一时间就被后续节点立即处理,这样减少等待时间。

object-response 事件中,我们根据事件uri参数判断是否是章节大纲,若是,那么uri对应的应该是/outline/1、/outline/2这样的路径,我们可以通过正则匹配出来。

然后我们创建二级Bot用来处理正文。因为这里的正文输出不需要JSON格式,所以我们通过 response_format: { type: "text" } 强制Bot输出文本。我们通过设置createBot的第一个参数root为content/${section} 将文本输出到 /content 数组中的section下标对应元素里。

接着我们调用 ling.close() 将流结束,这个操作会发送 finished 事件给客户端,客户端就可以结束SourceEvent。

最后我们将ling.stream通过pipleline进行流式连接,并设置好对应的HTTP Header,这样就可以正常发送数据流给客户端了。

ling.close();

// setting below headers for Streaming the data
res.writeHead(200, {
    'Content-Type': "text/event-stream",
    'Cache-Control': "no-cache",
    'Connection': "keep-alive"
});

pipeline(ling.stream, res);

实现前端UI交互

我们在创建Ling实例的时候配置了sse参数,所以接口返回的数据格式是Server-Sent Events格式,在前端使用起来会比较方便。

我们配置vite.config.js转发server接口:

  server: {
    allowedHosts: true,
    port: 4399,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        secure: false,
        rewrite: path => path.replace(/^\/api/, ''),
      },
    },
  },

然后改写App.vue。

<script setup lang="ts">
import { ref } from 'vue';
import { set, get } from 'jsonuri';

const question = ref('天空为什么是蓝色的?');
const content = ref({
  title: "",
  outline: [],
  content: [],
});

const update = async () => {
  if (!question) return;

  const endpoint = '/api/stream';

  const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.addEventListener("message", function (e: any) {
    let { uri, delta } = JSON.parse(e.data);
    const str = get(content.value, uri);
    set(content.value, uri, (str || '') + delta);
  });
  eventSource.addEventListener('finished', () => {
    console.log('传输完成');
    eventSource.close();
  });
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label><input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <!-- <textarea>{{ content }}</textarea> -->
      <h1>{{ content.title }}</h1>
      <div v-for="(item, i) in (content.outline as any)" :key="item.title + i">
        <h2>{{ item.title }}</h2>
        <p>{{ content.content[i] }}</p>
      </div>
    </div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: .85rem;
}

.input {
  width: 200px;
}

.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}

button {
  padding: 0 10px;
  margin-left: 6px;
}

textarea {
  width: 300px;
  height: 200px;
  font-size: 10px;
}
</style>

客户端代码非常简单,核心还是EventSource处理:

  const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
  eventSource.addEventListener("message", function (e: any) {
    let { uri, delta } = JSON.parse(e.data);
    const str = get(content.value, uri);
    set(content.value, uri, (str || '') + delta);
  });
  eventSource.addEventListener('finished', () => {
    console.log('传输完成');
    eventSource.close();
  });

注意一个小细节,我们之前自己写的SSE实现中,结束事件我们用的是 end ,而Ling中默认用 finished ,所以我们要把监听 end 事件改成监听 finished 事件

这样我们就实现了简易儿童版AI十万个为什么。

前端渲染Markdown内容

再补充一个细节,我们在生成章节内容的大模型节点中禁止模型输出任何Markdown标签,因为我们不想让这个模型输出标题,否则它就和大纲的章节标题重复了。但是如果我们只是禁止输出标题,允许正文中有一些Markdown标签(比如字体加粗),那么我们怎么在前端展示呢?

我们可以将Markdown通过marked库转成HTML然后进行渲染,这也是AI应用常用的前端内容渲染方式。

我们安装marked库:

pnpm i marked

然后改写App.vue,添加引用并改写内容渲染方式。

<script setup lang="ts">
import { marked } from 'marked';
...
</script>

<template>
...
      <div v-for="(item, i) in (content.outline as any)" :key="item.title + i">
        <h2>{{ item.title }}</h2>
        <p v-html=marked(content.content[i])></p>
      </div>
...
</template>

这样我们就可以在前端展示大模型输出的Markdown内容了。

要点总结

这节课我们学习了Ling框架的基础架构,通过完整的例子,了解了如何使用Ling来构建工作流并实现前端交互。我们可以看到,Ling是一个非常方便易用的异步实时响应工作流框架。在后续的课程中我们还会继续使用它。

Ling的完整代码在GitHub仓库:https://github.com/WeHomeBot/ling

有兴趣的同学可以进一步深入研究它。我希望你不仅能够掌握Ling的用法,还能了解Ling的设计细节,有能力的同学欢迎一起改进Ling的代码,让它变得更加方便和强大。

课后练习

Ling的createBot默认只支持创建文本大模型对象,假设我们需要处理图片,给上面的例子增加配图功能,为每一篇文章生成一张封面图,我们应该怎么做呢?你可以课后动手修改项目代码试试看,遇到任何问题请分享到评论区,我会帮你解答。

完整的项目代码在 https://github.com/akira-cn/frontend-dev-large-model-era/tree/main/ling_sse

精选留言

  • 机智帅气的小雨

    2025-05-13 10:29:28

    月影老师,使用 Ling 之后,免费的 moonshot 会收到限速影响,请问除了充值还有其他的备用方案嘛
    作者回复

    monnshot和deepseek充值也都不是很贵,免费的我也没有找到特别好用的

    2025-05-14 08:32:01