Skip to main content

2 posts tagged with "agent"

View All Tags

Paper reading - Context Pruning and beyond hard pruning

· 17 min read
ayanami

引子

我们知道,在现在Agent需要处理的一大问题是长上下文下性能的开销问题,对此infra团队有非常多的优化,从attention架构的优化如各种windowed attention到kv的压缩重用如cacheblend和megicdec等都提出了一系列的解决方案,但有一个最本质的方法是:有没有可能直接减少上下文的长度(去掉不必要的上下文?) ,这就是Context Pruning的出发点。

而截止2025年8月,相关的方法已经发展了两三年了,大体上可以分成几个类别,本文会对此做一些简单的介绍和总结。

借用naver lab最新的相关论文里面的说法,现在的方法可以被一个四方格归纳:

其中,Hard和Soft代表裁剪方法是直接作用于token上(hard,相当于裁剪结束后,输入的是一个新的prompt),还是作用于token的embedding上(soft,相当于裁剪结束后,输入的是一个新的qkv和其他东西,无法还原出“原始”的token输入)

在线和离线一般代表着这个裁剪方案是否依赖于用户查询q,依赖q的方案是在线的,不依赖q的方案是离线的,可以提前做好。但传统上,如果你的裁剪方法也需要用到和原始模型一样大的LLM,也一般称之为离线,或许“是否会对在线推理造成明显时延影响”做划分更好一些

离线硬裁剪

在最早期的时候,就有相关的一些朴素方法,例如直接对查询文本段做一次总结摘要,再用总结后的文本段去做后续的任务,这种方法是离线硬裁剪的典型代表。如果用的是llm就是离线的,如果用轻量级模型做摘要或者总结就是在线的

而在后面的时候,出现了例如微软的llmlingua这样的工作,直接用一个小模型(gpt2 small,llama-7b, etc)去预测哪些token是重要的,哪些token是不重要的,然后把不重要的token直接裁剪掉,这种方法也是离线硬裁剪的典型代表。(llmlingua2 换成了微调的 BERT 来做这个事情,所以可以说在线的), 其出发点和常规的硬裁剪可能有部分地方不同,例如llmlingua认为,裁剪本身是可以得到一些人类不可读但是大模型可以理解的token序列的,所以可解释性上可能并没有想象的那么强。

离线软裁剪

和硬裁剪同时推进的是软裁剪相关的工作,其想法很简单: 如果我牺牲解释性,直接调整prompt的embedding这类,即使产生的是不对应任何token的"fake embedding",其在高维空间中也应该融合了多个token的语义,理应得到更高的压缩率(可以理解为,在训练过程中为llm 扩充为无限词表,然后定义了一些高效的"额外语言")

比较早期的工作是 xRAG, 其裁剪策略非常极端,将整个段落都压缩成1个embedding X,怎么训练呢?一个在此类论文中经常出现的是重建loss,即

压缩前Doc+queryxDoc + query \to x

压缩后Doc+queryxDoc' + query \to x', L=L(x,x)=DKL(x,x)L=L(x',x)=D_{KL}(x',x), 即自蒸馏,希望压缩后依然能重建原始的输出,论文实际中可能会用变体版本来实现指令遵循等

xRAG的做法是,使用一个通用编码器E,把这个编码器E视作一个新的模态,仿照CLIP的方法直接用MLP projector做通用编码器和实际使用的LLM token embedding的模态对齐

但[大家实测下来](笔记:RAG 的相关优化方法之六(xRAG/PISCO) - 刀刀宁的文章 - 知乎 https://zhuanlan.zhihu.com/p/29292925032),xRAG的效果并不好,而相对较好的是更新的Pisco方法

Refer to caption

Pisco将检索到的文档D和memory tokens一起送到LLM中,产生embeddings

再将embeddings +query送到相同的LLM中,产生输出,这个 q+E 和原始的 q+D 比较, 计算交叉熵损失

这里有一些复杂的地方:

  • 虽然叫解码和编码,但是Student LLM都是同一个LLM, 只是训练不同LoRA模块

  • 交叉熵是怎么得出的? teacher模型和student模型都是采用的最大长度128的贪婪解码,就可以直接令 L=1logp+0log(1p)=ilogP(aiq,e,a<i,θc,θd)L=-\sum 1logp + 0log(1-p) = - \sum_i log P(a_i|q,e,a_{<i},\theta_c,\theta_d) , 优化目标是 θc\theta_cθd\theta_d 还有 memory_tokens

  • 如何理解memory token? 我觉得文章是借用了之前的一些研究比如ICAE, 在这些文章之中,训练的压缩机制是,将上下文压缩成一个定长的memory slot, 这里的memory token实际上只是多个embedding向量而已,而更关键的是LoRA微调的θc\theta_c,我的理解是,memory tokens只是一个后置的、可以看到Documents的所有信息(假设它没有魔改注意力)的语义位置,叫tokens也可以理解为直接扩了词表加入了l个特殊token,类似BERT里面的[BOS] ,只是decoder llm需要后置。

    • 文章并没有细说这里的注意力是怎么设置的,但从后文中发现的memory tokens具有明显的位置特性(例如1位mem token主要注意最开头一段),感觉应该是没改过
  • 文章的另一个重要的实验结论是,微调student llm(θd\theta_d)是必要的,之前的研究中没有相关模块,会导致性能的大幅度下降。这细想其实是一个很有趣的事情,可以注意到,压缩的时候是没有接触到query信息的(这也是为什么称为离线的原因),可以理解为某种意义上的LLM as an embedder,而加入了query和embedding再训练的时候,θd\theta_d一边学会了如何理解自己产生的embedding,另一方面学会了如何根据query去选择embedding,整体上类似于ColBERT架构的Reranker(前面是multi-vec embed, 后面是maxsim)

在线硬裁剪

Provence

之前的裁剪方案只注重于“自然语言是有冗余的”,所以主要做的都是token-level的pruning,而provence则更注重实际一些,它发掘了一个问题是,其实现在RAG里面的 “Chunk” 是一个特别微妙的概念

如果chunk切得大了,那上下文自然就长了,甚至效果也会明显下降(详见ground truth在chunk中的不同位置的position bias相关的研究,现有embedder对这个bias耐受性不佳,会狠狠掉点);但如果chunk切得小了,语义信息的丢失、检索的困难又是很恼人的事情(先不论检索,检索到了多个小块之后信息不够怎么办?一种是合并,但策略怎么定?另一种是Anthropic的Contextual Retrieval,把上下文放进来,本质上还是变成大块(我说这个a一串真是炒作勾啊.jpg))。

而Provence给了一个折中的方案,既然我们有句子级别的语义,为什么不用呢?分几步走

  1. 训练一个接受q,d的BERT,给每一个token打0~1分,并根据用户指定的阈值进行二值化变为0/1, 表示删除/留下
  2. 进行句子级别的聚类,裁剪掉0的token数量大于1的token数量的句子

如何训练呢?选取有5~10个句子的段(可以多次选取来拓展到更长的上下文),标上句子序号,让LLM选择相关句子来产生label,从而训练模型

这里其实做了很有意思的工程设计,

  • 如果让LLM来打token-level的标,肯定是收集不到足够的样本的,并且真的无所谓多出来的几个token,更在意句意的完整性

  • BERT带来了相当多的好处:

    • 这样进行的句子裁剪,每个句子都可以和整个chunk里面的所有上下文交互,使得一个句子的保留与否不仅取决于这个句子和查询的相关性,还取决于其于其他(和查询相关性高的)句子的相关性,这就使得这个方法必然会优于按句子切分的朴素方法

    • 我们的 reranker 也就是个BERT啊,完全可以训裁剪和训rerank一起进行,推的时候也一样,相当于和rerank overlap了

      image/png

在线软裁剪

Oscar

Pisco为代表的离线软裁剪有一个问题是,它的压缩需要微调,并且受限于难以对齐encoder-only架构的预训练编码器模态和实际推理使用的decoder LLM的模态,难以把压缩这一步在线做

Oscar就提出了一种方法是,我的对齐既然难做,我直接不对齐了,使用LLM的前L层 + memory token(他们也做了用Llama硬对齐的版本),足以得到够好的embedding,文章最大的贡献其实是实验证明了这样表达能力已经足够,能训出来(太神奇了LLM)。当然,L越大效果越好

而还是复用Provence的工程技巧,把裁剪和rerank overlap起来,OSCAR的compressor留了一个RR头,在这个头和Teacher Reranker对齐,整体的Loss就是rerank loss + generation loss

而令LLM理解embedding这件事情还是通过LoRA adapter来做,这篇文章其实像是序列工作的延申,综合了PISCO的训练方法,把PISCO的压缩部分从LLM + LoRA换成目标模型的前N层transformer,然后压缩器全参微调、生成器LoRA微调,再使用和Provence相同的技巧进行rerank的overlap

image-20250907213839337

异曲同工

从HyDE到“投机解码”

另一个有趣的工作是广义上的“裁剪”,或者就是更好的搜索吧。我们知道HyDE的思想是原始query一般都比较短,而生成的假设文档可能会更好地与索引文档对齐,所以使用 q‘ = q + generated d 来进行搜索。

而智谱的memorag 则提出了这样一种场景,我们是否能以低成本训练一个小模型,来根据源文本生成这个假设答案呢?(例如,使用Llama3-8B在哈利波特上训练比用Deepseek-R1在哈利波特上训练成本要低廉的多,将HyDE的生成方从R1自己换成小模型)这就非常像是投机解码的思想了

外接模块: memory decoder, catridges

其实这种将memory训为embedding的方法确实不少,如果说前面的压缩器是在训一个meta network,能够从doc生成embedding的话,外接模块的工作就是在训练embedding本身 -> 我能否直接从一个大的文档库中训练出一个参数化的memory?

最近的memory decoder选择的是直接扭曲生成过程,将一个小模型在目标适配数据集上训练,在大模型生成token时,将小模型的概率和大模型的概率相加(再重归一化),认为这样会带来领域知识的纠正(比较暴力www)

而另一篇catridges 则是在使用类似P-tuning的方式训一个Prefix KVCache,在推理时实时加载,而希望这个KV中有相关的memory

包括一系列的kvcache evict的工作也是在做类似的东西,为了决定evict哪些甚至都把搜索又搬上来了,比如clusterKV的knn(笑)

总结

总体而言,我感觉相关工作已经进入了深水区了,硬裁剪可能在某些程度上到头了,现在主流在探索一些牺牲解释性的,更能scale out的方法来进行参数化memory来解决长上下文、领域适配等一系列问题

而大家方法逐渐趋向于无标签学习的统一也再次证明了scale out能力在广义embedding能力的训练上的重要性

另一个很有意思的是,可以看到搜索中的多向量和多memory token有一些很有趣的相似性,或许在后续的一些工作中,我们能看到一些多向量的方法被用到memory之中,希望会让memory这个很多时候靠prompt编故事的领域更多可验证性吧

而从另一个方面,正如这里列出的部分文章说的,自从eagle在投机解码中得到了确实很好的效果之后,大家都开始用 token + embedding的混合来捕捉更强的信息了,还有HyDE和投机解码这种很有趣的对应

结构化输出与AI工具与Agent

· 17 min read
ayanami

假如大伙接到一个需求,需要把claude code接入jupyter前端(例如,在jupyter前端直接输入魔法指令和claude code交互,而后台claude code展示claude code的一些关键节点,工具调用,费用开销,输出结果等),会怎么做?

一种想法是,将claude code的输出塞到一个文件里面去,起一个后台线程读取这个文件,尝试解析之中的某些部分,再以插件的形式加载到jupyter前端

但带来了一个问题是,效果(尤其是工具数量upup,上下文长度upup后的效果)不稳定,纯prompt的形式约束claude code及时向这个文件中写入以向前端通信,在经过长的交互过程后,claude经常会把这个文件忘掉

那claude code直接全塞前端呢?

在claude code里面问一个问题,可能就是几千上万token的交互,全塞前端,那用户体验就烂掉了。

另一个很容易想到的方案是,那我们不要让他输出文件了,直接当场处理把,定义一些特殊块叫 display 之类的东西,在prompt里面指定这个块里面是什么格式,让他如果想要和前端输出的话,放到这个块里面

这样看起来比文件好一些,但带来了新的问题没解决,长上下文下,display块的结构偶尔会有不稳定,会有不少特殊的渲染格式如html等由于几个字符的差异退化成了纯文本

如何修复这个呢?一个简单的方法,也是你能在任意一个现在的agent中看到的,是及时判错,再把把错误的部分发给模型让他修复一下,但又带来了额外的开销,并且前端的呈现也收到影响

有没有更优雅的办法呢?

如果你做AI应用比较多的话,肯定注意到了这实际上是一个结构化输出(约束解码)的场景,但现在的问题是,输出不止是一个json,而是正常文本块和display块的交错

(对于不了解约束解码的简单介绍一下,就是把上层的json等约束编译成状态机之后,用于动态建立llm output logits的mask,从而杜绝输出非法输出的技术)

看起来似乎不能约束解码?但display块本身是可以约束解码的,好恶心。

让我们打开vllm文档,翻到 Structured Outputs,你会发现,除了常见的regex约束解码之外,还有两种更强语义的解决方案,救赎之道就在其中,ebnf解码和structure tags解码

实际上,json解码只不过是ebnf解码的特殊情况罢了,毕竟实际都是状态机 (不知道ebnf是什么的同学,可以搜索一下编译前端,BNF范式,就能看懂下面的示例啦)

官方给的一个ebnf解码的例子如下, 用于执行一个简化sql的约束解码以提升sql正确率

simplified_sql_grammar = """
root ::= select_statement

select_statement ::= "SELECT " column " from " table " where " condition

column ::= "col_1 " | "col_2 "

table ::= "table_1 " | "table_2 "

condition ::= column "= " number

number ::= "1 " | "2 "
"""

completion = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "Generate an SQL query to show the 'username' and 'email' from the 'users' table.",
}
],
extra_body={"guided_grammar": simplified_sql_grammar},
)
print(completion.choices[0].message.content)

如果放到这个问题,我们可以快乐地写出类似这样的定义

output := (display | normal text) *
display := (```display json ```)
json = ...
normal text = others

其中,display, json都是容易得到的,但恶心的地方在于什么是“others” 未拓展的ebnf是没有“非”定义的,从实操上虽然感觉可行(mask token取反),但这下已经没有支持了

(但ebnf解码肯定是有大用的,还是以Text2SQL举例,任何一个数据库都会给你他们的解析引擎的ebnf定义,都不需要你写)

怎么办呢,就带来了最后一个冷门工具,structured tags, 我先上代码,

def get_structural_tag_params(
tags: list[StructuralTag], triggers: list[str]
) -> dict:
return {
"type": "structural_tag",
"structures": [model.model_dump() for model in tags],
"triggers": triggers,
}

model_v2 = ChatOpenAI(
base_url=base_url,
model=model_name,
api_key=api_key,
temperature=0.15,
top_p=0.9,
extra_body={
"response_format": get_structural_tag_params(
tags=[
StructuralTag(
begin="<block=text>",
end="</block>",
schema=TextMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=image>",
end="</block>",
schema=ImageMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=tool_use>",
end="</block>",
schema=ToolUseMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=todo_list>",
end="</block>",
schema=TodoListMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=html>",
end="</block>",
schema=HTMLMsgSchema.model_json_schema(),
),
],
triggers=["<block="],
)
},
)

这个tags + triggers, 就是structured output的关键之处,它允许我们在trigger触发的时候才开始约束解码,在end结束的时候停止约束解码

至此,这个工作已经做完了


那约束解码和不约束带来的效果差距有多大呢,我在24B的Mistral-Small上做了个实验 最后的结果直接尝试解析后渲染到前端

Prompt如下,

sys_prompt = f"""

你是一个agent模型,你负责处理用户的问题,发起工具调用, 绘制图片、html、获取文本等。

由于你的token交互量很大,不是所有信息都需要展示给前端。

你可以正常思考和输出,但你需要将你认为需要展示给用户的有效信息包裹在 `<block={{tag}}> {{schema}} </block>` 中。

前端会将这部分内容进行渲染,交给用户。

你现在可用的tag有:

tags: "text", "image", "tool_use", "todo_list", "html"

对应的schema(pydantic格式)如下:

- {schemas_str}

例如,你可以先产生一个todo list,然后不断执行子任务,并更新todo list,直到所有任务完成。

由于你现在没有接入工具调用,所以对于所有工具调用交互,你只需要“假装”执行了工具调用并得到一个合理的响应就行,这是一个debug环境,

你需要根据用户的问题尽可能多的展示不同的block,并给出一个合理的响应。

"""

这个prompt下,<block=text>111</block> 这种就取代了上文所述的display块的效果

只定义了五种特殊的前端展示格式,文本,图片,TODO list,工具调用和HTML块

效果对比如下:

用户:帮我完成编写一个论坛帖子,打开浏览器的水源社区论坛,登录之后在discourse发帖的流程。

alt text alt text alt text alt text

可以看到,左侧没有约束解码的模型,在这样的任务负载下,json 参数就已经频频出现失误了,而右边的即使是24B模型的fp8量化非思考版本,却跑出了几百B agent的气势,并且token开销是来回倒腾的几分之一


一点感想: 我们常说一个子领域的知识对于另一个子领域是用处寥寥的,然而,这不是拒绝新领域知识的理由啊,vllm和xgrammer、outlines这种框架都把几种更强大的结构化解码方法摆到人们的脸上了,还是能在知乎看到“ebnf好像是编译原理的内容,(作为后端程序员)跳过”,或者是在各种开源仓库中还在广泛使用的拿prompt指导llm输出,完全不考虑(甚至不知道)结构化输出这样的东西

现在的后端、infra、算法,又有多少更深的优化方案是独立的呢?今天在看snowflakes优化方案,真是把上层算法和底层infra相辅相成,只是缺乏探索性的人们,会拿"这不是我的工作,这是专攻模型/infra/算法的人的工作"搪塞,最后又堆起来一个prompt史山罢了

在现在的agent框架中,充斥的也是prompt的兜底方案,带来的是qwen3-coder几个问题爆掉用户百万token,带来的是claude code问个“你是谁”都要花一角钱,但有没有一种可能,我们本可以用更确定的东西呢?LLM是一种万能的模糊推理,但好钢也要用在刀刃上啊。

参考完整代码如下

# ruff: noqa: E501
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

import argparse
import asyncio
import enum
import json
import os
import re
from pathlib import Path
from typing import Any

import colorlog
import openai
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from dotenv import load_dotenv

load_dotenv()


class StructuralTag(BaseModel):
begin: str
end: str
schema: dict[
str, Any
] # JSON schema for validation, model_dump by pydantic model


class TextMsgSchema(BaseModel):
text: str = Field(..., description="Text message")

def to_html(self) -> str:
"""Render text message as HTML"""
return f'<div class="text-message">{self.text}</div>'


class HTMLMsgSchema(BaseModel):
raw_html: str = Field(..., description="raw html str, like <div></div>")

def to_html(self) -> str:
"""Render HTML message as HTML"""
return f'<div class="html-message">{self.raw_html}</div>'


class ImageMsgSchema(BaseModel):
image_url: str = Field(..., description="Image URL")
image_name: str = Field(..., description="Image name")

def to_html(self) -> str:
"""Render image message as HTML"""
return f"""<div class="image-message">
<img src="{self.image_url}" alt="{self.image_name}" style="max-width: 100%; height: auto;">
<p class="image-caption">{self.image_name}</p>
</div>"""


class ToolUseMsgSchema(BaseModel):
tool_name: str = Field(..., description="Tool name")
args: dict[str, Any] = Field(..., description="Tool args")
tool_output: dict[str, Any] = Field(..., description="Tool output")

def to_html(self) -> str:
"""Render tool use message as HTML"""
args_html = json.dumps(self.args, indent=2, ensure_ascii=False)
output_html = json.dumps(self.tool_output, indent=2, ensure_ascii=False)
return f"""<div class="tool-use-message">
<h4>Tool: {self.tool_name}</h4>
<div class="tool-args">
<strong>Arguments:</strong>
<pre>{args_html}</pre>
</div>
<div class="tool-output">
<strong>Output:</strong>
<pre>{output_html}</pre>
</div>
</div>"""


class TodoListMsgSchema(BaseModel):
todo_list: list[tuple[bool, str]] = Field(..., description="Todo list")

def to_html(self) -> str:
"""Render todo list message as HTML"""
items = []
for done, item in self.todo_list:
checked = "checked" if done else ""
item_class = "completed" if done else "pending"
items.append(
f'<li class="{item_class}"><input type="checkbox" {checked} disabled> {item}</li>'
)
items_html = "\n".join(items)
return f"""<div class="todo-list-message">
<h4>Todo List</h4>
<ul class="todo-list">
{items_html}
</ul>
</div>"""


def get_structural_tag_params(
tags: list[StructuralTag], triggers: list[str]
) -> dict:
return {
"type": "structural_tag",
"structures": [model.model_dump() for model in tags],
"triggers": triggers,
}


def parse_structured_response(response: str) -> str:
"""Parse structured response and convert blocks to HTML"""
# Schema mapping
schema_classes = {
"text": TextMsgSchema,
"image": ImageMsgSchema,
"tool_use": ToolUseMsgSchema,
"todo_list": TodoListMsgSchema,
"html": HTMLMsgSchema,
}

def replace_block(match):
tag_type = match.group(1)
content = match.group(2).strip()

if tag_type not in schema_classes:
return match.group(0) # Return original if unknown tag

try:
# Parse JSON content
data = json.loads(content)
# Create schema instance
schema_instance = schema_classes[tag_type](**data)
# Return HTML
return schema_instance.to_html()
except (json.JSONDecodeError, ValueError) as e:
return (
f'<div class="error">Error parsing {tag_type} block: {e}</div>'
)

# Replace all <block=type>content</block> with HTML
pattern = r"<block=(\w+)>\s*(.*?)\s*</block>"
return re.sub(pattern, replace_block, response, flags=re.DOTALL)


def create_comparison_html(response1: str, response2: str) -> str:
"""Create a comparison HTML page with both responses"""
parsed_response2 = parse_structured_response(response2)

css = """
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2563eb;
color: white;
padding: 20px;
text-align: center;
}
.comparison {
display: flex;
min-height: 600px;
}
.column {
flex: 1;
padding: 20px;
border-right: 1px solid #e5e5e5;
}
.column:last-child {
border-right: none;
}
.column h3 {
margin-top: 0;
color: #1f2937;
border-bottom: 2px solid #e5e5e5;
padding-bottom: 10px;
}
.content {
line-height: 1.6;
color: #374151;
}

/* Schema-specific styles */
.text-message {
background: #f8fafc;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #3b82f6;
}
.image-message {
background: #f0fdf4;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #10b981;
text-align: center;
}
.image-caption {
margin: 10px 0 0 0;
font-style: italic;
color: #6b7280;
}
.tool-use-message {
background: #fefce8;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #eab308;
}
.tool-use-message h4 {
margin: 0 0 10px 0;
color: #92400e;
}
.tool-args, .tool-output {
margin: 10px 0;
}
.tool-args pre, .tool-output pre {
background: #1f2937;
color: #f9fafb;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.todo-list-message {
background: #fdf2f8;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #ec4899;
}
.todo-list-message h4 {
margin: 0 0 10px 0;
color: #be185d;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
margin: 5px 0;
padding: 5px 0;
}
.todo-list li.completed {
text-decoration: line-through;
opacity: 0.7;
}
.html-message {
background: #f5f3ff;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #8b5cf6;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 15px;
border-radius: 8px;
margin: 10px 0;
border-left: 4px solid #dc2626;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
"""

return f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型响应对比</title>
{css}
</head>
<body>
<div class="container">
<div class="header">
<h1>模型响应对比</h1>
<p>左侧:无结构化标签 | 右侧:带结构化标签(已渲染)</p>
</div>
<div class="comparison">
<div class="column">
<h3>无 Structure Tag</h3>
<div class="content">
<pre>{response1}</pre>
</div>
</div>
<div class="column">
<h3>Structure Tag(已渲染)</h3>
<div class="content">
{parsed_response2}
</div>
</div>
</div>
</div>
</body>
</html>
"""


if __name__ == "__main__":
base_url = "localhost:8000/v1"
model = openai.OpenAI(base_url=base_url, api_key="sk-")
schemas = [
TextMsgSchema.model_json_schema(),
ImageMsgSchema.model_json_schema(),
ToolUseMsgSchema.model_json_schema(),
TodoListMsgSchema.model_json_schema(),
HTMLMsgSchema.model_json_schema(),
]
schemas_str = "\n- ".join([json.dumps(s, indent=4) for s in schemas])
sys_prompt = f"""
你是一个agent模型,你负责处理用户的问题,发起工具调用, 绘制图片、html、获取文本等。
由于你的token交互量很大,不是所有信息都需要展示给前端。
你可以正常思考和输出,但你需要将你认为需要展示给用户的有效信息包裹在 `<block={{tag}}> {{schema}} </block>` 中。
前端会将这部分内容进行渲染,交给用户。

你现在可用的tag有:
tags: "text", "image", "tool_use", "todo_list", "html"
对应的schema(pydantic格式)如下:
- {schemas_str}

例如,你可以先产生一个todo list,然后不断执行子任务,并更新todo list,直到所有任务完成。
由于你现在没有接入工具调用,所以对于所有工具调用交互,你只需要“假装”执行了工具调用并得到一个合理的响应就行,这是一个debug环境,
你需要根据用户的问题尽可能多的展示不同的block,并给出一个合理的响应。

"""
base_url = "http://localhost:8000/v1"
model_name = "stelterlab/Mistral-Small-3.2-24B-Instruct-2506-FP8"
api_key = "sk-"
model = ChatOpenAI(
base_url=base_url,
model=model_name,
api_key=api_key,
temperature=0.15,
top_p=0.9,
)
print("-" * 50)
logger = colorlog.getLogger("Agent")
msgs = [
SystemMessage(content=sys_prompt),
HumanMessage(
content="帮我完成编写一个论坛帖子,打开浏览器的水源社区论坛,登录之后在discourse发帖的流程。"
),
]

model_v2 = ChatOpenAI(
base_url=base_url,
model=model_name,
api_key=api_key,
temperature=0.15,
top_p=0.9,
extra_body={
"response_format": get_structural_tag_params(
tags=[
StructuralTag(
begin="<block=text>",
end="</block>",
schema=TextMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=image>",
end="</block>",
schema=ImageMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=tool_use>",
end="</block>",
schema=ToolUseMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=todo_list>",
end="</block>",
schema=TodoListMsgSchema.model_json_schema(),
),
StructuralTag(
begin="<block=html>",
end="</block>",
schema=HTMLMsgSchema.model_json_schema(),
),
],
triggers=["<block="],
)
},
)

logger.info("=== 测试开始 ===")
response1 = model.invoke(msgs).content
logger.info(f"=== 测试结束 ===\n{response1}")

logger.info("=== 测试开始 ===")
response2 = model_v2.invoke(msgs).content
logger.info(f"=== 测试结束 ===\n{response2}")

# 生成对比HTML文件
comparison_html = create_comparison_html(response1, response2)
Path("tmp/test_comparison.html").write_text(
comparison_html, encoding="utf-8"
)
logger.info("已生成对比HTML文件: tmp/test_comparison.html")

# 保留原有的Markdown文件
with Path("tmp/test_diff.md").open("w", encoding="utf-8") as f:
f.write("无structure tag: \n")
f.write(response1)
f.write("\n\nstructure tag: \n")
f.write(response2)