# blend **Repository Path**: pengminghua/blend ## Basic Information - **Project Name**: blend - **Description**: Openhandx-blend 是一个基于 Java 构建 AI 大语言模型应用框架。它提供非常轻量级的工具和组件,让开发者能够更容易地将大模型与业务数据、工具和系统集成起来。它从企业架构和产品需求等更高的视角出发,直击AI大模型在企业业务系统中落地的痛点。具备以下功能:1、业务数据注入;2、支持函数回调;3、支持多轮对话;4、完善流程处理;5、兼容多种大模型;6、未来多模态支持。 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 1 - **Created**: 2026-02-28 - **Last Updated**: 2026-03-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: AI, 大模型应用, Java, 人工智能 ## README # Openhandx-blend:为Java生态量身打造的AI大语言模型应用框架 作者: 彭明华 联系方式:pengminghua1976@foxmail.com 最后更新: 2026-02-28 Openhandx-blend 是一个基于 Java 构建 AI 大语言模型(后面简称大模型)应用框架。它提供了丰富的工具和组件,让开发者能够更容易地将大模型与业务数据、工具和系统集成起来。**将此成果分享给大家**。 ## 一、 意义 当前的大模型绝大多数都是Python语言,Python语言在数据分析和AI方面深耕,具有天然优势。但在企业级应用方面java占绝对主流,大模型的应用和推广方面需要优先考虑。 ### 1、现状 #### 直接成本 * **人员成本**:要么招聘专业的Python大模型应用开发工程师,增加人力成本;要么让现有Java研发人员从头学习Python及其大模型框架,产生学习成本。 * **运维成本**:主流大模型应用使用Python服务,需要额外资源部署,其中包含更多的硬件投入和运维人员的投入。 #### 更高视角的风险 * **产品设计风险**:大模型应用开发工程师与原有业务研发工程师需充分沟通与对接(实际情况也许是:做大模型调用的不懂具体业务,做具体业务的不懂大模型调用,只站在自身角度考虑导致沟通困难),需要磨合相互理解对方需求和能力做出最合理的设计。 * **研发风险**:业务需求的新增与变更涉及大模型调用的代码研发,必定牵扯到中间的业务代码,业务逻辑被分割,研发和测试、验证链路变长,时间、风险和人员投入增加。 * **服务质量风险**:多一层大模型调用中间服务,增加延迟影响客户体验,无法与原有的Java微服务生态相结合统一管理、统一运维监控体系。 ### 2、基于 Java 构建大语言模型应用框架优点 * **降低人员学习成本**:java开发人员无需跨越开发语言门槛,当作工具开箱即用,无需额外学习以提升开发效率。 * **提高质量和效率**:业务研发人员就是大模型调用人员,无需沟通就能将业务与大模型的能力相结合做到最优设计,无中间层服务减少研发和测试链路,减少调用延迟,提高开发效率和用户体验。 * **降低运维风险**:直接使用原有运维监控系统,无需调整或引入新的体系。 * **充分利用Java优势**:充分利用成熟的Java技术生态,无论从服务治理、高并发等与现有系统做到无缝结合,特别是老系统非常友好。 Openhandx-blend 正是为应对上述挑战而生。它从企业架构和产品需求等更高的视角出发,直击AI大模型在企业业务系统中落地的痛点。它是一个非常轻量级的工具类, 除了依赖外部AI大模型服务本身,无需依赖额外的独立服务或框架,因此具有易学性和易集成性。 ## 二、 设计哲学 ### 1、简化概念,锁定核心价值 * **业务数据注入**:有别于传统的RAG(检索增强生成)模式,本框架允许开发者在调用大模型时,直接将业务数据注入上下文中,实现与业务系统的无缝整合。 * **支持函数回调**:对应function Calling,实现了AI大模型回调本地函数的能力,集成多轮函数回调的方式,将大模型的决策与业务系统的功能进行整合。 * **支持多轮对话**:支持AI大模型进行持续多轮对话,实现与业务流程的连续处理。 * **完善流程处理**:支持通用和并行两种模式,可实现流程节点跳转与并行处理,兼顾调用简单、高性能、同时满足业务流程的复杂需求。 * **兼容多种大模型**:采用OpenAI的协议和规范,经过测试支持DeepSeek、百度千帆、阿里通义千问、智谱AI、月之暗面、字节跳动豆包、腾讯混元大模型等国内主流大模型。 * **未来多模态支持**:虽然主流大模型API接口对多模态支持尚未普及(目前千问qwen-vl-max模型支持),但是这个是未来的趋势,blend从协议上已经支持。 ### 2. 简化实现,高内聚低耦合工具类 ![blend 总体架构图](media/framework.png) 构建了三个工具包: * **common**:提供基础工具类,如:http通讯交互、互序列化、反序列化、异常处理等。 * **chat**:提供大模型调用工具类,如:大模型调用、大模型多轮回调本地函数、大模型多轮对话等,一个Client类实现所有功能。 * **workflow**:提供工作流工具类,如:工作流定义、工作流执行等,支持单线程和并行两种模式。 ### 3. chat工具包 * **提供简易模板**:message可采用{}占位符,将业务数据注入大模型调用中。 * **支持函数回调**:Client的functionManager可以注册本地类函数,支持注解方式扫描注册。支持多轮函数调用,直到AI大模型返回最终结果。 * **支持多轮对话**:实现IMultiRoundDialogue接口,支持上下文保持。可以通过多轮对话补充业务数据,直到问题解决,实现更复杂的业务场景。 ### 4. workflow工具包 * **独立使用**:workflow不依赖chat可以单独使用,通常的业务代码也能应用。 * **通用模式**:每个工作流节点顺序执行,支持每个节点完成后自定义逻辑调转到下一个节点,满足复杂需求的同时,兼顾开发和处理简单。 ![blend workflow](media/workflow.png) * **并行模式**:可定义并行节点,采用多线程执行;支持与通用模式流程嵌套,兼顾高并发的性能要求。 ![blend parallelworkflow](media/parallelworkflow.png) ## 三、开发文档 在开始使用本框架前,理解大模型的本质至关重要。一言以蔽之:AI大模型更像一个博学的‘人’,擅长根据模糊的指令和给定的信息,生成相对合理的答案。 因此,掌握基础的提示词工程,会让你的开发事半功倍。 ### 1 chat工具包 #### 1.1 model模块 ##### 1.1.1、RoleType: system, user, assistant, tool。 * system: 系统角色,用于设置大模型的行为和回复风格。 * user: 用户角色,用于表示用户的输入和问题。 * assistant: 助手角色,用于表示大模型的回复和回答。 * tool: 工具角色,用于表示大模型调用的外部函数或服务。 ##### 1.1.2、Request 向大模型发起请求的对象,采用OpenAI的协议,包含以下内容: * model: 大模型的名字,决定本次请求使用的大模型。 * messages: 请求的消息列表,包含文本消息、模板消息、FunctionCall消息、多条消息,并且每个消息对应RoleType角色,一般情况下我们发出的是用户消息。 * tool: 模型可使用的工具,定义AI回调用户的函数(包含函数名,函数描述,函数参数及说明,返回值说明)。 * context: 请求上下文,用于存储请求相关的业务数据、模板消息数据。 | message类型 | content数据类型 | 说明 | |:--------------------|:------------|:------------------------------------------------------| | SimpleMessage | String | 纯文本消息,用于用户输入或系统回复 | | TemplateMessage | String | 带{}占位符的文本消息,用于注入业务数据,数据取自Request中Context的templateData | | FunctionCallMessage | String | 回调函数后返回值序列化的文本,用于告诉大模型函数回调后的结果 | | ListMessage | List | 保存多条消息的对象数据,可嵌套,用于复杂对象 | 其中 ListMessage 支持大模型未来多模态能力的调用 ##### 1.1.3、Response 向大模型发起请求后的响应对象,采用OpenAI的协议,包含以下内容: * id: 响应的唯一标识,用于跟踪和匹配请求。 * model: 大模型的名字,本次请求使用的大模型。 * choices: 回复请求的消息列表,包含文本消息、ToolCall消息(调用FunctionCall的消息)。 ##### 2、Client 调用大模型的核心工具类,负责向大模型发起请求和处理响应。 ###### 2.1、创建Client 创建Client时需要传入大模型的服务地址和访问密钥,其他参数可选。 * baseUrl: 大模型的服务地址,如:https://api.deepseek.cn/v1/chat/completions。 * apiKey: 大模型的访问密钥,用于身份验证。 * charset: 字符编码,默认UTF-8。 * httpConfig: http通讯配置,包含连接超时、请求超时、响应超时、最大连接数、代理、最大连接路由数等。若不配置,将使用默认值。 public void templateCallTest() throws Exception { String API_KEY = "sk-..."; String BASE_URL = "https://api.deepseek.com/chat/completions"; String DEEP_MODEL = "deepseek-chat"; Client client = new Client(BASE_URL,API_KEY); ... } ###### 2.2、发起调用 * stepCall: 单步调用,返回一次请求的大模型回复消息,也是call调用的基础方法。 * call: 完整调用,支持大模型的多轮调用与回复消息。 * streamCall: 流式调用,返回大模型的回复消息流,只支持大模型的单步调用流式回复消息。 call的参数和返回值与stepCall相同,不同的是call支持多轮调用,会自动维护上下文。参数如下: | 参数名称 | 参数类型 | 说明 | |:-------------|:--------------------|:----------------------------| | req | Request | 完成的Reqest对象,可定义协议范围内的所有请求消息 | | dialogue | IMultiRoundDialogue | 多轮对话的应答事件 | | model | String | 大模型的名字,本次请求使用的大模型 | | message | String | 向大模型发起的消息内容 | | externalData | Map | 外部数据,用于向大模型发起的消息内容中注入业务数据 | | templateData | Map | 模板数据,message参数具备{}占位符,取自该map中的数据 | * Request 参数的请求方式 Client client = new Client(BASE_URL,API_KEY); Request req = new Request(); req.setModel(MODEL); req.setMessages(Arrays.asList(new TemplateMessage("{addr}今天天气好吗"))); Request.Context context = new Request.Context(); Map templateData = new LinkedHashMap<>(); templateData.put("addr","北京"); Map externalData = new LinkedHashMap<>(); externalData.put("地址坐标数据","北京坐标:latitude:116.40,longitude:39.9;上海坐标:latitude:106.40,longitude:32.9"); externalData.put("坐标天气数据","坐标latitude:116.40,longitude:39.9,对应{\"温度\":3,\n" + " \"天气\":\"晴天\",\n" + " \"风向\":\"北\",\n" + " \"风力\":2}"); context.getExternalData().putAll(externalData); context.getTemplateData().putAll(templateData); req.setContext(context); client.call(req); * templateData + externalData 参数的请求方式 Client client = new Client(BASE_URL,API_KEY); Map templateData = new LinkedHashMap<>(); templateData.put("addr","北京"); Map externalData = new LinkedHashMap<>(); externalData.put("地址坐标数据","北京坐标:latitude:116.40,longitude:39.9;上海坐标:latitude:106.40,longitude:32.9"); externalData.put("坐标天气数据","坐标latitude:116.40,longitude:39.9,对应{\"温度\":3,\n" + " \"天气\":\"晴天\",\n" + " \"风向\":\"北\",\n" + " \"风力\":2}"); context.getExternalData().putAll(externalData); context.getTemplateData().putAll(templateData); client.call(MODEL,templateData,"{addr}今天天气好吗",externalData); * 多模态调用方式 目前千问的qwen-vl-max模型支持 File f = new File(this.getClass().getResource("/").getPath() + "../../../../../media/framework.png"); byte[] data; try(InputStream is = Files.newInputStream(f.toPath())){ data = StreamUtils.read(is); } String base64 = Base64.getEncoder().encodeToString(data); Client client = new Client(BASE_URL,API_KEY); Request req = new Request(); req.setModel(MODEL); ListMessage list = new ListMessage(RoleType.user,new ArrayList<>()); list.getContent().add(new ListMessage.Data("text","这张图里有什么?")); Map image = new LinkedHashMap<>(); image.put("url","data:image/png;base64," + base64); list.getContent().add(new ListMessage.Data("image_url",image)); req.setMessages(Arrays.asList(list)); client.call(req); ##### 3、函数回调 FunctionManager有register有两种方式:通过functionManager手动代码注册、采用AIFunction、AIParameter注解方式注册 | 参数名称 | 参数类型 | 说明 | |:--------------------|:-----------|:---------------------| | name | String | 方法名称 | | function | Fun | 方法的详细定义 | | method | Method | 回调函数的实体方法 | | description | String | 方法详情 | | instance | Object | 方法对应的类实例 | | parameters | Tool.Function.Parameters | 方法的参数详细定义 | | instance | Object | 扫描实例注解方式注册,用于有实例的类方法 | | instance | Object | 扫描类注解方式注册,用于静态方法 | * Request参数 + 手动代码注册回调 的请求方式 Client client = new Client(BASE_URL,API_KEY); Map properties = new HashMap<>(); properties.put("latitude",new Tool.Function.Parameters.Property(ParameterType.number)); properties.put("longitude",new Tool.Function.Parameters.Property(ParameterType.number)); Tool.Function.Parameters parameters = new Tool.Function.Parameters(properties,"latitude","longitude"); client.functionManager().register("get_weather", FunctionTest.class.getMethod("get_weather",double.class,double.class),null, "通过经纬度获取当前天气",parameters); properties = new HashMap<>(); properties.put("name",new Tool.Function.Parameters.Property(ParameterType.string,null,Arrays.asList("北京","上海"),null,null)); properties.put("isFull",new Tool.Function.Parameters.Property(ParameterType.bool)); parameters = new Tool.Function.Parameters(properties,"name"); client.functionManager().register("coordinates", FunctionTest.class.getMethod("coordinates",String.class),null, "通过城市获取经纬度",parameters); client.call(MODEL,"北京和上海今天天气好吗"); * 注解方式函数注册回调 的请求方式 Client client = new Client(BASE_URL,API_KEY); client.functionManager().register(new FunctionTest3()); client.call(MODEL,"北京和上海今天天气好吗"); AIFunction: 回调方法注解,用于注册大模型可调用的函数。 AIParameter: 回调参数注解,用于AIFunction对应的函数定义参数。 其中注解部分定义代码如下: @AIFunction(description="通过城市获取经纬度") public String coordinates(@AIParameter(name="name",enumValue={"北京","上海"}) String name){ if("北京".equals(name)){ return "{\"latitude\":116.20,\"longitude\":39.69}"; }else{ return "\"latitude\":121.29,\"longitude\":31.14}"; } } @AIFunction(description="通过经纬度获取当前天气") public String get_weather(@AIParameter(name="latitude")float latitude, @AIParameter(name="longitude")float longitude){ if(latitude-116.20<0.01){ return "{\"温度\":3,\n" + " \"天气\":\"晴天\",\n" + " \"风向\":\"北\",\n" + " \"风力\":2}"; }else{ return "{\"温度\"6,\n" + " \"天气\":\"大雨\",\n" + " \"风向\":\"北\",\n" + " \"风力\":5}"; } } ##### 4、 多轮对话 需要实现IMultiRoundDialogue接口, IMessage dialogue(Request req, Response resp, String content)throws BlendException; 当dialogue返回null时,多轮对话结束。 Client client = new Client(BASE_URL,API_KEY); boolean[] flag = new boolean[]{true}; client.call(MODEL, "北京今天天气好吗", new IMultiRoundDialogue() { @Override public AbstractMessage dialogue(Request req, Response resp, String content) throws BlendException { System.out.println(content); if(flag[0]){ flag[0] = false; return new SimpleMessage("北京:{\"温度\":3,\n" + " \"天气\":\"晴天\",\n" + " \"风向\":\"北\",\n" + " \"风力\":2}"); }else{ return null; } } }); ### 2、workflow工具包 工作流工具包,用于定义和执行复杂的业务流程。将复杂任务分解为可管理的多个子Node,每个Node负责执行一个具体的任务。提高代码组件的复用能力, blend将工作流这个工具包进一步抽象及拔高,可以作为一个独立的模块,提供给开发者用在任何地方,不限于AI大模型的调用。 workflow工具包分为两种: * Workflow:基于注解的工作流定义工具类,单线程执行,逻辑简单,便于调试。 * ParallelWorkflow: 并行工作流定义工具类,多线程执行,逻辑复杂,适用于需要并发处理的场景。 ### 2.1相关接口 * IContext:工作流上下文接口 * INode:工作流节点接口 * INodeResult:工作流节点执行结果接口 * IDispatcher:工作流调度器接口 ### 2.1.1 IContext 用于在工作流中传递数据和状态,用于存放复杂的结果或中间数据 | 方法名称 | 返回类型 | 说明 | |:---------------------------|:---------------------|:---------------| | getNodesResults() | List> | 返回已执行Node的结果 | | getData() | Map | 用于存放复杂的结果或中间数据 | | getParam() | T | 工作流启动发起的参数 | | getNodeStep(String nodeId) | int | Node Id已执行的次数 | | getStep() | int | 当前执行所有Node的总次数 | ### 2.1.2 INode 每个Node负责执行一个具体的任务 R event(IContext context, T param, int currNodeStep)throws Exception; | 参数名称 | 参数类型 | 说明 | |:---------------------------|:--------------|:---------------| | context | IContext | 工作流上下文 | | param | T | 工作流启动发起的参数 | | currNodeStep | int | 当前执行Node的次数 | ### 2.1.3 INodeResult 用于在工作流中传递节点执行结果 | 方法名称 | 返回类型 | 说明 | |:---------------------------|:---------------------|:---------------| | getNodeId() | String | 返回Node的Id | | getResult() | R | 返回已执行Node的结果 | | getStep() | int | 当前执行Node的次数 | ### 2.1.4 IDispatcher 根据INodeResult判断是否继续执行下一个Node,用于在工作流中协调不同Node的执行顺序 String dispatch(IContext context, R result, String nodeId, int currNodeStep); | 参数名称 | 参数类型 | 说明 | |:-------------|:--------------|:------------| | context | IContext | 工作流上下文 | | result | R | 当前Node执行的结果 | | nodeId | String | 当前Node Id | | currNodeStep | int | 当前执行Node的次数 | ### 2.2 workflow(general) 由于workflow是单线程执行,并支持链式编程,因此可以定义非常简单的工作流,只需要添加Node即可。 workflow有两个方法: * addNode:添加工作流的节点 * execute: 开始执行工作流 addNode有三个重载方法,分别是: * addNode(String nodeId, INode node) 添加一个Node,nodeId为节点的唯一标识符,返回值为一个node Id,简化流程的配置。 Workflow workflow = new Workflow<>("test"); workflow.addNode("init",new Init()) .addNode("proc1",new Proc1()) .addNode("proc2",new Proc2()) .addNode("last",new Last()); Object result = workflow.execute(); Node节点的实现代码如下: public static class Init implements INode { @Override public String event(IContext context, Object param, int currNodeStep) throws Exception { System.out.println("Init:" + param + ",step:" + currNodeStep + ",thread:" + Thread.currentThread()); return "proc1"; } } * addNode(String id, INode node, String nextId) 添加一个Node,nodeId为节点的唯一标识符,nextId为一个node Id,Node本身的返回值可以是业务数据,减少流程对代码的侵入,提高Node的复用能力。 Workflow workflow = new Workflow<>("test"); workflow.addNode("init",new Init(),"proc2") .addNode("proc2",new Proc2(),"last") .addNode("last",new Last()); Object result = workflow.execute(); * addNode(String nodeId, INode node, IDispatcher dispatcher) 添加一个Node,nodeId为节点的唯一标识符,node为节点的实例,dispatcher根据节点的返回值判断是否继续执行下一个Node。这种方式更加灵活,由结果动态决定是否继续执行下一个Node。 * execute() 执行工作流并返回最后一个Node的结果 ### 2.3 ParallelWorkflow 支持并行工作流定义工具类,多线程执行,适用于需要并发处理的场景。 ParallelWorkflow也有两个方法: * addNode:添加工作流的节点 * execute: 开始执行工作流 addNode有五个重载方法,分别是: * addNode(String id, INode node) * addNode(String id, INode node, String nextId) * addNode(String id, INode node, IDispatcher dispatcher) 以上三种方式与Workflow的addNode方法功能相同,且单线程执行。 * addNode(String id, List> nodes,String nextId) 添加多个Node为一个并行节点,id为节点的唯一标识符,nextId为一个node Id,Node本身的返回值可以是业务数据,减少流程对代码的侵入,提高Node的复用能力。 ParallelWorkflow workflow = new ParallelWorkflow<>("test"); workflow.addNode("init",new Init()) .addNode("proc1", Arrays.asList(new Proc1(),new Proc3()),"proc1-2") .addNode("proc1-2", Arrays.asList(new Proc1(), new Proc3(), WorkflowTest.getWorkflow()), (context, result, nodeId, currNodeStep) -> { System.out.println("result:" + result + ",step:" + currNodeStep + ",thread:" + Thread.currentThread()); return "proc2"; }) .addNode("proc2",new Proc2()) .addNode("last",new Last()); List result = workflow.execute(); * addNode(String id, List> nodes, IDispatcher> dispatcher) 添加多个Node为一个并行节点,nodeId为节点的唯一标识符,node为节点的实例,dispatcher根据节点的返回值判断是否继续执行下一个Node。这种方式更加灵活,由结果动态决定是否继续执行下一个Node。 * execute() 执行工作流并返回最后一个Node的结果 ## 四、示例:一个基于多轮对话两个AI模型相互聊天的例子 为了更直观地展示 IMultiRoundDialogue 接口的强大之处,我们实现了一个有趣的场景:让DeepSeek和阿里通义千问两个AI模型互相聊天。下面的代码中,client(DeepSeek,扮演男生)和 client2(通义千问,扮演女生)通过IMultiRoundDialogue进行交互,核心逻辑在于重写 dialogue 方法,将一方的回复作为另一方的输入,从而形成对话闭环。 ### 1、代码实现 Client client = new Client(BASE_URL,API_KEY); client.setDebug(false); Map data1 = new HashMap<>(); data1.put("角色","你作为刚工作的男生"); Client client2 = new Client(BASE_URL2,API_KEY2); client2.setDebug(false); Request req2 = new Request(); req2.setModel(MODEL2); req2.setContext(new Request.Context()); req2.getContext().getExternalData().put("角色","你作为刚工作的女生"); String firstMessage = "你好,我们认识了解有很长时间了,我喜欢你"; req2.setMessages(new ArrayList<>(Arrays.asList(new SimpleMessage(RoleType.assistant,firstMessage)))); AtomicInteger index = new AtomicInteger(); System.out.println("\n\n千问 女生:\n" + firstMessage); client.call(MODEL, firstMessage, data1,new IMultiRoundDialogue() { @Override public IMessage dialogue(Request req, Response resp, String content) throws BlendException { System.out.println("\n\nDeepSeek 男生:\n" + content); if(index.getAndIncrement()>=10){ return null; }else{ try { req2.getMessages().add(new SimpleMessage(content)); Response resp2 = client2.call(req2); String message = resp2.getChoices().get(0).getMessage().getContent(); req2.getMessages().add(new SimpleMessage(RoleType.assistant,message)); System.out.println("\n\n千问 女生:\n" + message); return new SimpleMessage(message); } catch (BlendException e) { throw e; } catch (Exception e) { throw new BlendException("调用错误",e); } } } }); ### 2、聊天结果 千问 女生: 你好,我们认识了解有很长时间了,我喜欢你 DeepSeek 男生: (有些紧张地摸了摸后脑勺)啊...谢谢你的心意。其实我也一直觉得和你相处很愉快,只是我现在刚工作,还有很多需要学习和适应的地方,可能暂时没法很好地经营一段感情。 千问 女生: (轻轻低头,手指不自觉地绕着衣角)嗯...我明白的。刚入职那会儿,我连打印机卡纸都要偷偷百度怎么处理呢(笑)。每天加班到地铁都快停运了,回家倒头就睡,连外卖软件都懒得打开... 不过...(抬头看了你一眼,又快速移开视线)上周五下班路上,看到街角新开了一家很温馨的咖啡馆,暖黄色的灯光特别柔和。当时就在想,要是哪天能和你一起坐在窗边,分享一块提拉米苏就好了... 你觉得...等我慢慢适应了工作节奏,可以约你去试试看吗? DeepSeek 男生: (被对方细致的描述触动,语气温和)那家店我也注意到了...(停顿片刻)提拉米苏确实是我最喜欢的甜点。 千问 女生: (眼睛微微亮起来,嘴角不自觉上扬)真的吗?太巧了!我第一次尝提拉米苏还是实习时,被主管带去那家店庆祝项目上线...结果紧张得把咖啡洒在了新买的衬衫上,还被同事笑称是"咖啡渍限定款职场初体验"(不好意思地笑了笑)。 (从包里拿出一张小小的、边角有些卷的便签纸)啊...这个...是我上周路过时顺手画的店门口小速写。虽然画得不太好,但想说——如果哪天你愿意,我们可以一起去找找看,那块最完美的提拉米苏在哪里。(指尖轻轻点了点画纸右下角,那里用铅笔写着一行小字:"等我学会不把咖啡洒在衬衫上那天") 要...试试看吗? DeepSeek 男生: (接过便签纸仔细端详,眼神柔和)画得真好...(将便签小心地夹进笔记本)下个月我有个项目要收尾,等忙完这阵...或许可以约在某个不用加班的周五傍晚? 千问 女生: (轻轻呼出一口气,像是卸下了什么小小的负担,脸颊微红)嗯!周五傍晚...我连闹钟都想好了——提前一小时设好,免得又像上次那样,踩着点冲进地铁,结果发现穿反了衬衫扣子...(低头看了看自己今天整齐的衣领,忍不住笑出来) (从包里拿出一个小巧的保温杯,拧开盖子闻了闻)对了,我最近在学煮桂花乌龙茶,说是能让人静心...虽然第一次煮得像中药汤,被室友吐槽"这怕不是养生版苦咖啡"(不好意思地眨眨眼)。不过现在总算有点样子啦~要是那天你愿意,我可以带一杯去,配提拉米苏,甜一点,也暖一点。 (把保温杯轻轻放在桌上,声音轻而认真)那...我就开始期待那个周五啦。 DeepSeek 男生: (看着保温杯上氤氲的热气,嘴角微微上扬)桂花乌龙...听起来很温暖。(稍作停顿)那说好了,等项目结束,我们一起去尝尝那家店的提拉米苏。 千问 女生: (指尖轻轻碰了碰温热的杯壁,望着袅袅升腾的水汽,声音像被暖风拂过)嗯,说好了。 (忽然想起什么,从手机相册里翻出一张照片,屏幕朝向你——是那家咖啡馆傍晚时分的橱窗,暖光斜斜地铺在木质桌面上,玻璃上还沾着几颗细小的雨珠)今天午休时路过拍的...下雨了,但店里灯都亮着,像一小块浮在街角的琥珀。 (把手机收好,抬眼笑起来,眼睛弯成温柔的弧度)原来等待一件事,也可以这么轻快啊。 那...我先去改PPT啦——这次争取不把页码标错。(站起身,又微微停顿)对了,保温杯我明天会洗得干干净净的。 周五见。 DeepSeek 男生: (目送对方离开,轻轻摩挲着笔记本里的便签)周五见。(低头继续工作时,嘴角还带着淡淡的笑意) 千问 女生: (走到办公室门口,没回头,只是轻轻抬手挥了挥,马尾辫在灯光下晃出一道柔和的弧线) 窗外,初夏的风悄悄拂过玻璃,把一张被遗忘在窗台边的便利贴吹得微微颤动——上面用浅蓝色字迹写着: 「PPT第17页,记得加那个会笑的小图标😊 还有… 周五傍晚六点,不见不散。」 风停了,纸角静静垂落,像一句还没说出口、却早已落定的约定。 DeepSeek 男生: (整理文件时发现了那张便利贴,小心地把它和之前的便签放在一起,在日程表上认真标注)六点...(轻声重复着,眼神温柔)不见不散。 ...... ## 五、本人分享其他开源项目 * https://gitee.com/pengminghua/projects