关于 Endpoint
Endpoint 是 dify 在 v1.0 插件机制中引入的一种全新扩展类型,它为 Dify 新增了一个 API 入口。插件可以通过代码自定义 API 的逻辑。从开发者的角度来看,这相当于支持在 Dify 内运行一个 HTTP 服务器,且服务器的实现由开发者自己决定。我们可以通过下面这张图更好地理解 Endpoint:
Endpoint 的具体逻辑实现在 Plugin 中。当用户启动 Endpoint 时,Dify 会为用户生成一串随机的 URL,格式如 `https://abcdefg.dify.ai`。当 Dify 接收到该 URL 请求时,原始的 HTTP 报文会被转发至插件。此时插件的行为类似于 Serverless,接收并处理请求。
然而,这只是基础功能。为了让插件能够调用 Dify 内的 App,我们引入了反向调用功能。完成这整套协议后,IM 类需求得到了一定程度的闭环。可是,Endpoint 的潜力远不止于此。本文将深入探讨 Endpoint 的能力边界,让我们进一步了解它的实际应用。
审视能力本质
在最初设计 Endpoint 时,它是一个用于处理 Webhook 的模块,旨在通过插件逻辑将复杂且难以泛化的低代码/无代码工作流抽象为可复用的代码实现。因此,我们还引入了诸如反向调用等功能。但随着使用的深入,我们发现 Endpoint 实际上具有更广泛的用途。它本质上就是一个 Serverless HTTP 服务器,虽然不支持长连接协议如 WebSocket,但它可以实现大多数 HTTP 服务器的能力。例如,可以用它来构建套壳 Chatbot。
WebApp 模板
目前 Dify 的 WebApp 还较为基础,风格化定制部分几乎为空白。由于我们难以精雕细琢每一个场景和具体的端侧需求,为何不通过 Endpoint 来实现这些需求呢?假设一个插件中内置了多个 Endpoint,每个 Endpoint 都是风格不同的模板,例如极简风、二次元可爱风、韩式风格或欧美风格。这些不同风格的 Endpoint 背后其实都是同一个 Chatbot,只是皮肤不同。这就形成了一个天然的模板市场。
通过这一规范,我们理论上可以开放 WebApp,让 Dify 用户拥有更多选择,避免被局限在 Dify 生态内。这为用户带来了更好的体验,但这也要求 Dify 生态逐渐趋于繁荣。要实现这一目标,我们还有很长的路要走。
实现
作为示例,我们可以先实现一个简单的版本,包含两个 Endpoint:一个用于展示页面,另一个用于请求 Dify。我们不会在此列出所有开发步骤,具体开发规范请参考帮助文档。
快速开始:https://docs.dify.ai/zh-hans/plugins/quick-start
<!DOCTYPE html>
<html lang="zh">
<body>
<header>
<h1>{{ bot_name }}</h1>
</header>
<div class="chat-container">
<div id="chat-log"></div>
<div class="input-container">
<input type="text" id="user-input" placeholder="Press Enter or click Send after typing" />
<button id="send-btn">Send</button>
<button id="reset-btn">Reset</button>
</div>
</div>
<script>
const botName = '{{ bot_name }}';
let conversationId = localStorage.getItem('conversation_id') || '';
const chatLog = document.getElementById('chat-log');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const resetBtn = document.getElementById('reset-btn');
sendBtn.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', function (event) {
if (event.key === 'Enter') {
sendMessage();
}
});
resetBtn.addEventListener('click', resetConversation);
async function sendMessage() {
const message = userInput.value.trim();
if (!message) return;
appendMessage(message, 'user');
userInput.value = '';
const requestBody = {
query: message,
conversation_id: conversationId
};
try {
const response = await fetch('./pink/talk', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
let botMessageContainer = appendMessage('', 'bot');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('nn');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.answer) {
botMessageContainer.textContent += data.answer;
}
if (data.conversation_id) {
conversationId = data.conversation_id;
localStorage.setItem('conversation_id', conversationId);
}
} catch (error) {
console.error('Error:', error, line);
}
}
}
} catch (error) {
console.error('Error:', error);
appendMessage('Request failed, please try again later.', 'bot');
}
}
function appendMessage(text, sender) {
const messageEl = document.createElement('div');
messageEl.className = `message ${sender}`;
if (sender === 'bot') {
messageEl.textContent = botName + ': ' + text;
} else {
messageEl.textContent = text;
}
chatLog.appendChild(messageEl);
chatLog.scrollTop = chatLog.scrollHeight;
return messageEl;
}
function resetConversation() {
localStorage.removeItem('conversation_id');
conversationId = '';
chatLog.innerHTML = '';
}
</script>
</body>
</html>
我们用一个 Endpoint 来简单地 host 它:
from collections.abc import Mapping
import os
from werkzeug import Request, Response
from dify_plugin import Endpoint
class NekoEndpoint(Endpoint):
def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:
with open(os.path.join(os.path.dirname(__file__), "girls.html"), "r") as f:
return Response(
f.read().replace("{{ bot_name }}", settings.get("bot_name", "Candy")),
status=200,
content_type="text/html",
)
然后实现一个调用接口的 Endpoint:
from collections.abc import Mapping
import json
from typing import Optional
from werkzeug import Request, Response
from dify_plugin import Endpoint
class GirlsTalk(Endpoint):
def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:
"""
Invokes the endpoint with the given request.
"""
app: Optional[dict] = settings.get("app")
if not app:
return Response("App is required", status=400)
data = r.get_json()
query = data.get("query")
conversation_id = data.get("conversation_id")
if not query:
return Response("Query is required", status=400)
def generator():
response = self.session.app.chat.invoke(
app_id=app.get("app_id"),
query=query,
inputs={},
conversation_id=conversation_id,
response_mode="streaming",
)
for chunk in response:
yield json.dumps(chunk) + "nn"
return Response(generator(), status=200, content_type="text/event-stream")
当一切完成后,打开对应的 Endpoint 即可看到这个页面:
这样,我们就给 Dify 套上了不同的皮肤,并在此基础上,经过优化和修改后,形成了一个具备丰富功能的 UI,甚至还加上了 TTS,完成了半个语音 Chatbot:
OpenAI 兼容接口
-
Dify 支持多家厂商的模型,为什么不把 Dify 作为 API 网关来使用呢?
-
为什么 Dify 的 App 无法使用 OpenAI 的兼容格式返回?
我们一直关注这些问题,但 Dify 的 API 是有状态的 API,相比 OpenAI 的无状态 API,它能够实现更多的功能,特别是在上下文管理方面。Dify 通过 `conversation_id` 控制每一次的对话,而 OpenAI 只能每次携带全量上下文。此外,Dify 的 API 提供了更多自定义和扩展的空间。
虽然我们没有立即实现 OpenAI 兼容接口,但在引入 Endpoint 和反向调用后,这些本应耦合在 Dify 本体中的功能变成了插件。通过开发插件调用 Dify 的 LLM,我们可以实现完整的模型转 OpenAI 的需求,也可以通过插件将 Dify API 转为 OpenAI 格式,满足部分用户需求。
实现
以模型统一接口为例,我们可以设置一个 Endpoint 组,如下所示:
settings:- name: api_keytype: secret-inputrequired: truelabel:en_US: API keyzh_Hans: API keypt_BR: API keyplaceholder:en_US: Please input your API keyzh_Hans: 请输入你的 API keypt_BR: Please input your API key- name: llmtype: model-selectorscope: llmrequired: falselabel:en_US: LLMzh_Hans: LLMpt_BR: LLMplaceholder:en_US: Please select a LLMzh_Hans: 请选择一个 LLMpt_BR: Please select a LLM- name: text_embeddingtype: model-selectorscope: text-embeddingrequired: falselabel:en_US: Text Embeddingzh_Hans: 文本嵌入pt_BR: Text Embeddingplaceholder:en_US: Please select a Text Embedding Modelzh_Hans: 请选择一个文本嵌入模型pt_BR: Please select a Text Embedding Modelendpoints:- endpoints/llm.yaml- endpoints/text_embedding.yaml
完成后,我们可以选择想要转换成 OpenAI 接口的模型,如 Claude。
我们可以通过简化后的伪代码来实现,完整代码可以参考:https://github.com/langgenius/dify-official-plugins/blob/main/extensions/oaicompat_dify_model/endpoints/llm.py
class OaicompatDifyModelEndpoint(Endpoint):
def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:
"""
Invokes the endpoint with the given request.
"""
llm: Optional[dict] = settings.get("llm")
data = r.get_json(force=True)
prompt_messages: list[PromptMessage] = []
if not isinstance(data.get("messages"), list) or not data.get("messages"):
raise ValueError("Messages is not a list or empty")
for message in data.get("messages", []):
pass
tools: list[PromptMessageTool] = []
if data.get("tools"):
for tool in data.get("tools", []):
tools.append(PromptMessageTool(**tool))
stream: bool = data.get("stream", False)
def generator():
if not stream:
llm_invoke_response = self.session.model.llm.invoke(
model_config=LLMModelConfig(**llm),
prompt_messages=prompt_messages,
tools=tools,
stream=False,
)
yield json.dumps({
"id": "chatcmpl-" + str(uuid.uuid4()),
"object": "chat.completion",
"created": int(time.time()),
"model": llm.get("model"),
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": llm_invoke_response.message.content
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": llm_invoke_response.usage.prompt_tokens,
"completion_tokens": llm_invoke_response.usage.completion_tokens,
"total_tokens": llm_invoke_response.usage.total_tokens
}
})
else:
llm_invoke_response = self.session.model.llm.invoke(
model_config=LLMModelConfig(**llm),
prompt_messages=prompt_messages,
tools=tools,
stream=True,
)
for chunk in llm_invoke_response:
yield json.dumps({
"id": "chatcmpl-" + str(uuid.uuid4()),
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": llm.get("model"),
"choices": [{
"index": 0,
"delta": {"content": chunk.delta.message.content},
"finish_reason": None
}]
}) + "nn"
return Response(generator(), status=200, content_type="event-stream" if stream else "application/json")
当一切完成后,我们可以使用 curl 命令来测试实现是否成功。
异步事件触发
基于事件触发的 Workflow 一直是社区中反馈较多的需求,许多用户的场景涉及异步事件。例如,先发送一个任务,等待任务执行完成后触发信号,继续执行剩余的流程。以前在 Dify 中无法实现这样的需求,但现在通过 Endpoint,我们可以将其拆解为两个 Workflow。第一个 Workflow 发起任务并正常退出,第二个 Workflow 接收 Webhook 信号并继续执行后续流程。
尽管这个过程对用户来说不够直观,但它确实解决了一些问题,例如 AI 批量生成的长文先发布到审核平台,用户审核后点击接受,触发事件返回 Dify 完成后续的发布流程。虽然这在当前技术框架下有些复杂,但未来几个月我们将为 Workflow 提供直接的事件触发能力,进一步优化整体体验。