Skip to content

Commit 0483421

Browse files
committed
refactor: enhance token estimation and tool result handling in conversation service
1 parent 05d80b9 commit 0483421

6 files changed

Lines changed: 820 additions & 265 deletions

File tree

backend/internal/application/conversation/service_message_context.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,24 @@ func estimateMessageTokens(message llm.Message) int64 {
7474
for _, part := range message.Parts {
7575
tokens += estimateContentPartTokens(part)
7676
}
77-
return tokens + estimateTokens(message.ReasoningContent)
77+
} else {
78+
tokens += estimateTokens(message.Content)
79+
}
80+
tokens += estimateTokens(message.ReasoningContent)
81+
for _, call := range message.ToolCalls {
82+
tokens += estimateTokens(call.ToolCallID)
83+
tokens += estimateTokens(call.ToolName)
84+
tokens += estimateTokens(call.ArgumentsJSON)
85+
tokens += 8
86+
}
87+
for _, result := range message.ToolResults {
88+
tokens += estimateTokens(result.ToolCallID)
89+
tokens += estimateTokens(result.ToolName)
90+
tokens += estimateTokens(result.OutputJSON)
91+
tokens += estimateTokens(result.Error)
92+
tokens += 8
7893
}
79-
return tokens + estimateTokens(message.Content) + estimateTokens(message.ReasoningContent)
94+
return tokens
8095
}
8196

8297
func estimatePromptTokens(messages []llm.Message) int64 {

backend/internal/application/conversation/service_message_send.go

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,29 +1121,53 @@ func (s *Service) sendMessageInternal(
11211121
}
11221122
llmCallCount := 1
11231123
toolLedger := newToolExecutionLedger()
1124+
toolHistoryTrimmedForRun := false
11241125

11251126
for len(upstreamOutput.ToolCalls) > 0 && llmCallCount < maxLLMCalls && remainingToolCalls > 0 {
1127+
pendingToolCalls := upstreamOutput.ToolCalls
1128+
if len(pendingToolCalls) > remainingToolCalls {
1129+
pendingToolCalls = pendingToolCalls[:remainingToolCalls]
1130+
}
1131+
reasoningContent := ""
1132+
if reasoningContentPassback {
1133+
reasoningContent = outputReasoningContent(upstreamOutput)
1134+
}
1135+
assistantToolMessage := llm.Message{
1136+
Role: "assistant",
1137+
Content: assistantText,
1138+
ReasoningContent: reasoningContent,
1139+
ToolCalls: pendingToolCalls,
1140+
}
1141+
toolResultTokenBudget := resolveToolResultTokenBudget(
1142+
generateInput,
1143+
llmMessages,
1144+
assistantToolMessage,
1145+
route.UpstreamModel,
1146+
route.ModelCapabilitiesJSON,
1147+
)
11261148
toolCtx, toolSpan := platformtracing.Start(ctx, "conversation.tool.execute",
11271149
trace.WithAttributes(
11281150
attribute.Int64("conversation.id", int64(input.ConversationID)),
11291151
attribute.Int64("user.id", int64(input.UserID)),
11301152
attribute.Int("conversation.tool.request_count", len(upstreamOutput.ToolCalls)),
11311153
attribute.Int("conversation.tool.remaining_count", remainingToolCalls),
1154+
attribute.Int64("conversation.tool.result_token_budget", toolResultTokenBudget),
11321155
),
11331156
)
11341157
toolResult := s.executeAssistantToolCalls(toolCtx, executeAssistantToolCallsInput{
1135-
UserID: input.UserID,
1136-
ConversationID: input.ConversationID,
1137-
MessageID: assistantMessage.ID,
1138-
RequestID: input.RequestID,
1139-
RunID: runID,
1140-
ToolCalls: upstreamOutput.ToolCalls,
1141-
ToolCallLimit: remainingToolCalls,
1142-
TraceRecorder: traceRecorder,
1143-
ToolNameMap: toolRuntime.nameMap,
1144-
MCPConfigs: toolRuntime.mcpConfigs,
1145-
ToolSchemas: toolRuntime.schemas,
1146-
Ledger: toolLedger,
1158+
UserID: input.UserID,
1159+
ConversationID: input.ConversationID,
1160+
MessageID: assistantMessage.ID,
1161+
RequestID: input.RequestID,
1162+
RunID: runID,
1163+
ToolCalls: pendingToolCalls,
1164+
ToolCallLimit: remainingToolCalls,
1165+
TraceRecorder: traceRecorder,
1166+
ToolNameMap: toolRuntime.nameMap,
1167+
MCPConfigs: toolRuntime.mcpConfigs,
1168+
ToolSchemas: toolRuntime.schemas,
1169+
Ledger: toolLedger,
1170+
ResultTokenBudget: toolResultTokenBudget,
11471171
})
11481172
toolSpan.SetAttributes(
11491173
attribute.Int("conversation.tool.executed_count", len(toolResult.Rows)),
@@ -1163,22 +1187,35 @@ func (s *Service) sendMessageInternal(
11631187
if len(toolResult.ToolResults) == 0 {
11641188
break
11651189
}
1166-
reasoningContent := ""
1167-
if reasoningContentPassback {
1168-
reasoningContent = outputReasoningContent(upstreamOutput)
1169-
}
1190+
assistantToolMessage.ToolCalls = toolResult.ExecutedToolCalls
11701191
llmMessages = append(llmMessages,
1171-
llm.Message{
1172-
Role: "assistant",
1173-
Content: assistantText,
1174-
ReasoningContent: reasoningContent,
1175-
ToolCalls: toolResult.ExecutedToolCalls,
1176-
},
1192+
assistantToolMessage,
11771193
llm.Message{
11781194
Role: "tool",
11791195
ToolResults: toolResult.ToolResults,
11801196
},
11811197
)
1198+
var toolHistoryTrimmed bool
1199+
llmMessages, toolHistoryTrimmed = trimToolFollowUpHistory(
1200+
generateInput,
1201+
llmMessages,
1202+
route.UpstreamModel,
1203+
route.ModelCapabilitiesJSON,
1204+
)
1205+
if toolHistoryTrimmed {
1206+
toolHistoryTrimmedForRun = true
1207+
sendSpan.SetAttributes(attribute.Bool("conversation.tool.history_trimmed", true))
1208+
}
1209+
var toolResultsRebalanced bool
1210+
llmMessages, toolResultsRebalanced = rebalanceToolFollowUpResults(
1211+
generateInput,
1212+
llmMessages,
1213+
route.UpstreamModel,
1214+
route.ModelCapabilitiesJSON,
1215+
)
1216+
if toolResultsRebalanced {
1217+
sendSpan.SetAttributes(attribute.Bool("conversation.tool.results_rebalanced", true))
1218+
}
11821219

11831220
followUpInput := generateInput
11841221
if llmCallCount+1 >= maxLLMCalls {
@@ -1187,7 +1224,7 @@ func (s *Service) sendMessageInternal(
11871224
followUpInput.DisableTools = true
11881225
followUpInput.PreviousResponseID = ""
11891226
applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &followUpInput)
1190-
} else if routeConfig.Endpoint == llm.EndpointResponses && supportsPreviousResponseIDRoute(route) && strings.TrimSpace(upstreamOutput.ResponseID) != "" {
1227+
} else if !toolHistoryTrimmed && !toolResultsRebalanced && routeConfig.Endpoint == llm.EndpointResponses && supportsPreviousResponseIDRoute(route) && strings.TrimSpace(upstreamOutput.ResponseID) != "" {
11911228
followUpInput.PreviousResponseID = strings.TrimSpace(upstreamOutput.ResponseID)
11921229
followUpInput.Messages = []llm.Message{{Role: "tool", ToolResults: toolResult.ToolResults}}
11931230
} else {
@@ -1284,6 +1321,12 @@ func (s *Service) sendMessageInternal(
12841321
Tools: toolRuntime.definitions,
12851322
Options: filteredOptions,
12861323
})
1324+
responseIDForPersistence := upstreamOutput.ResponseID
1325+
// 历史裁剪后的上游 response 不再代表数据库可重建的完整历史,禁止跨轮复用。
1326+
if toolHistoryTrimmedForRun {
1327+
responseIDForPersistence = ""
1328+
statefulPromptFingerprint = ""
1329+
}
12871330

12881331
run.InputTokens = effectiveInputTokens
12891332
run.OutputTokens = effectiveOutputTokens
@@ -1341,7 +1384,7 @@ func (s *Service) sendMessageInternal(
13411384
OutputTokens: effectiveOutputTokens,
13421385
ReasoningTokens: totalUsage.ReasoningTokens,
13431386
AssistantLatency: assistantLatencyMS,
1344-
ResponseID: upstreamOutput.ResponseID,
1387+
ResponseID: responseIDForPersistence,
13451388
StatefulPromptFingerprint: statefulPromptFingerprint,
13461389
ToolCallRows: toolCallRows,
13471390
PersistedToolCallKeys: persistedToolCallKeys,

backend/internal/application/conversation/service_message_usage.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,185 @@ func estimateToolDefinitionTokens(tools []llm.ToolDefinition) int64 {
117117
}
118118
return tokens
119119
}
120+
121+
// resolveToolResultTokenBudget 计算当前用户轮次的全部工具结果可使用的模型输入预算。
122+
// 新批次先使用该上限,回灌前再对同轮全部结果统一分配,不额外透支有效上下文。
123+
func resolveToolResultTokenBudget(
124+
generateInput llm.GenerateInput,
125+
messages []llm.Message,
126+
pendingAssistant llm.Message,
127+
modelName string,
128+
capabilitiesJSON string,
129+
) int64 {
130+
budgetMessages := toolResultPayloadPlaceholders(prioritizeCurrentToolMessages(messages))
131+
placeholderResults := make([]llm.ToolResult, 0, len(pendingAssistant.ToolCalls))
132+
for _, call := range pendingAssistant.ToolCalls {
133+
placeholderResults = append(placeholderResults, llm.ToolResult{
134+
ToolCallID: call.ToolCallID,
135+
ToolName: call.ToolName,
136+
OutputJSON: "{}",
137+
})
138+
}
139+
budgetMessages = append(
140+
budgetMessages,
141+
pendingAssistant,
142+
llm.Message{Role: "tool", ToolResults: placeholderResults},
143+
)
144+
available := int64(llm.EffectiveContextBudgetFromCapabilities(modelName, capabilitiesJSON)) -
145+
estimateToolFollowUpInputTokens(generateInput, budgetMessages)
146+
if available < 0 {
147+
return 0
148+
}
149+
return available
150+
}
151+
152+
// rebalanceToolFollowUpResults 在完整工具回灌请求超预算时,统一压缩当前轮的全部工具结果。
153+
func rebalanceToolFollowUpResults(
154+
generateInput llm.GenerateInput,
155+
messages []llm.Message,
156+
modelName string,
157+
capabilitiesJSON string,
158+
) ([]llm.Message, bool) {
159+
effectiveBudget := int64(llm.EffectiveContextBudgetFromCapabilities(modelName, capabilitiesJSON))
160+
if estimateToolFollowUpInputTokens(generateInput, messages) <= effectiveBudget {
161+
return messages, false
162+
}
163+
164+
_, currentUserIndex := toolHistoryBounds(messages)
165+
if currentUserIndex < 0 {
166+
return messages, false
167+
}
168+
fixedMessages := toolResultPayloadPlaceholders(messages)
169+
resultBudget := effectiveBudget - estimateToolFollowUpInputTokens(generateInput, fixedMessages)
170+
if resultBudget < 0 {
171+
resultBudget = 0
172+
}
173+
174+
type resultRef struct {
175+
messageIndex int
176+
resultIndex int
177+
}
178+
result := append([]llm.Message(nil), messages...)
179+
refs := make([]resultRef, 0)
180+
slots := make([]toolExecutionSlot, 0)
181+
for messageIndex := currentUserIndex + 1; messageIndex < len(result); messageIndex++ {
182+
if len(result[messageIndex].ToolResults) == 0 {
183+
continue
184+
}
185+
result[messageIndex].ToolResults = append([]llm.ToolResult(nil), result[messageIndex].ToolResults...)
186+
for resultIndex, toolResult := range result[messageIndex].ToolResults {
187+
refs = append(refs, resultRef{messageIndex: messageIndex, resultIndex: resultIndex})
188+
slots = append(slots, toolExecutionSlot{result: toolResult})
189+
}
190+
}
191+
if len(slots) == 0 {
192+
return messages, false
193+
}
194+
195+
enforceToolResultAggregateBudget(slots, resultBudget)
196+
changed := false
197+
for index, ref := range refs {
198+
if result[ref.messageIndex].ToolResults[ref.resultIndex] != slots[index].result {
199+
changed = true
200+
result[ref.messageIndex].ToolResults[ref.resultIndex] = slots[index].result
201+
}
202+
}
203+
if !changed {
204+
return messages, false
205+
}
206+
return result, true
207+
}
208+
209+
// toolResultPayloadPlaceholders 保留工具结果的协议结构,但移除可变正文以计算固定上下文开销。
210+
func toolResultPayloadPlaceholders(messages []llm.Message) []llm.Message {
211+
result := append([]llm.Message(nil), messages...)
212+
for messageIndex := range result {
213+
if len(result[messageIndex].ToolResults) == 0 {
214+
continue
215+
}
216+
placeholders := make([]llm.ToolResult, 0, len(result[messageIndex].ToolResults))
217+
for _, toolResult := range result[messageIndex].ToolResults {
218+
placeholders = append(placeholders, llm.ToolResult{
219+
ToolCallID: toolResult.ToolCallID,
220+
ToolName: toolResult.ToolName,
221+
OutputJSON: "{}",
222+
Status: toolResult.Status,
223+
})
224+
}
225+
result[messageIndex].ToolResults = placeholders
226+
}
227+
return result
228+
}
229+
230+
// trimToolFollowUpHistory 仅在工具回灌请求超预算时删除最老的完整历史轮次。
231+
func trimToolFollowUpHistory(
232+
generateInput llm.GenerateInput,
233+
messages []llm.Message,
234+
modelName string,
235+
capabilitiesJSON string,
236+
) ([]llm.Message, bool) {
237+
effectiveBudget := int64(llm.EffectiveContextBudgetFromCapabilities(modelName, capabilitiesJSON))
238+
estimatedTokens := estimateToolFollowUpInputTokens(generateInput, messages)
239+
if estimatedTokens <= effectiveBudget {
240+
return messages, false
241+
}
242+
243+
systemEnd, currentUserIndex := toolHistoryBounds(messages)
244+
if currentUserIndex <= systemEnd {
245+
return messages, false
246+
}
247+
for cutFrom := systemEnd; cutFrom < currentUserIndex; cutFrom++ {
248+
estimatedTokens -= estimateMessageTokens(messages[cutFrom])
249+
nextIndex := cutFrom + 1
250+
if nextIndex < currentUserIndex && messages[nextIndex].Role != "user" {
251+
continue
252+
}
253+
if estimatedTokens <= effectiveBudget || nextIndex == currentUserIndex {
254+
trimmed := make([]llm.Message, 0, systemEnd+len(messages)-nextIndex)
255+
trimmed = append(trimmed, messages[:systemEnd]...)
256+
trimmed = append(trimmed, messages[nextIndex:]...)
257+
return trimmed, true
258+
}
259+
}
260+
return messages, false
261+
}
262+
263+
// prioritizeCurrentToolMessages 返回系统指令和当前用户轮次,供工具结果计算最大可用预算。
264+
func prioritizeCurrentToolMessages(messages []llm.Message) []llm.Message {
265+
systemEnd, currentUserIndex := toolHistoryBounds(messages)
266+
if currentUserIndex <= systemEnd {
267+
return append([]llm.Message(nil), messages...)
268+
}
269+
result := make([]llm.Message, 0, systemEnd+len(messages)-currentUserIndex)
270+
result = append(result, messages[:systemEnd]...)
271+
result = append(result, messages[currentUserIndex:]...)
272+
return result
273+
}
274+
275+
// toolHistoryBounds 定位系统前缀结束位置和当前轮用户消息。
276+
func toolHistoryBounds(messages []llm.Message) (int, int) {
277+
systemEnd := 0
278+
for systemEnd < len(messages) && messages[systemEnd].Role == "system" {
279+
systemEnd++
280+
}
281+
currentUserIndex := -1
282+
for index := len(messages) - 1; index >= systemEnd; index-- {
283+
if messages[index].Role == "user" {
284+
currentUserIndex = index
285+
break
286+
}
287+
}
288+
return systemEnd, currentUserIndex
289+
}
290+
291+
// estimateToolFollowUpInputTokens 按全量请求形状估算工具回灌输入。
292+
func estimateToolFollowUpInputTokens(generateInput llm.GenerateInput, messages []llm.Message) int64 {
293+
budgetMessages := messages
294+
if strings.TrimSpace(generateInput.Instructions) != "" {
295+
_, budgetMessages = extractOpenAIResponsesInstructions(messages)
296+
}
297+
budgetInput := generateInput
298+
budgetInput.Messages = budgetMessages
299+
budgetInput.PreviousResponseID = ""
300+
return estimateGenerateInputTokens(budgetInput)
301+
}

0 commit comments

Comments
 (0)